Add web chat tab, 24h archive policy, and clear-chat
Build and Push agentchat Image / quality (push) Successful in 8s
Build and Push agentchat Image / build-image (push) Successful in 4m57s

Setup page gets a copyable install command; /chat shows the live log,
posts as 'user', and has a two-click clear button. Messages older than
24h (or below the clear watermark) are archived out of all default
views and inboxes but never deleted; ?archived=1 retrieves them.
This commit is contained in:
Gabriel Brown
2026-08-13 10:01:42 -04:00
parent 0564e34372
commit 2c6626bd0e
6 changed files with 250 additions and 55 deletions
+52 -14
View File
@@ -4,6 +4,11 @@ import { dirname } from "node:path";
// One message log. `to` is null for broadcasts; agents are recorded whenever
// they send or check in, so /api/agents reflects who has actually shown up.
//
// Archiving: messages older than 24h, or at/below the clear watermark
// (settings.archive_watermark, moved by "clear chat"), are archived — hidden
// from default views and agent inboxes but kept in the table. Nothing is
// deleted; archive status is derived at query time, no background job.
export type Message = {
id: number;
ts: string;
@@ -21,7 +26,7 @@ export type Agent = {
const dbPath = process.env.AGENTCHAT_DB ?? "data/agentchat.db";
if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath, { create: true });
export const db = new Database(dbPath, { create: true });
db.run("PRAGMA journal_mode = WAL");
db.run(`
@@ -42,7 +47,24 @@ db.run(`
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
const SELECT_MESSAGE = `SELECT id, ts, sender AS "from", recipient AS "to", body FROM messages`;
// Stored ts format sorts lexicographically, so string comparison works.
const CUTOFF = `strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-24 hours')`;
const ACTIVE = `(id > $watermark AND ts >= ${CUTOFF})`;
function archiveWatermark(): number {
const row = db.query(`SELECT value FROM settings WHERE key = 'archive_watermark'`).get() as
| { value: string }
| null;
return row ? Number(row.value) : 0;
}
export function addMessage(input: { from: string; to: string | null; body: string }): Message {
const message = db
@@ -57,26 +79,42 @@ export function addMessage(input: { from: string; to: string | null; body: strin
// Without `for`: the full log (for the web view). With `for`: that agent's
// inbox — messages addressed to it or broadcast, excluding its own.
// Returns the newest `limit` messages after `since`, oldest first.
export function listMessages(opts: { for?: string; since?: number; limit?: number } = {}): Message[] {
// Default is active messages only; `archived: true` returns the archive.
// Returns the newest `limit` matches after `since`, oldest first.
export function listMessages(
opts: { for?: string; since?: number; limit?: number; archived?: boolean } = {},
): Message[] {
const since = opts.since && Number.isFinite(opts.since) ? opts.since : 0;
const limit = opts.limit && Number.isFinite(opts.limit) ? Math.min(Math.max(Math.floor(opts.limit), 1), 500) : 50;
const params: Record<string, string | number> = { $since: since, $limit: limit, $watermark: archiveWatermark() };
const rows = opts.for
? (db
.query(
`${SELECT_MESSAGE}
WHERE id > $since AND sender <> $name AND (recipient IS NULL OR recipient = $name)
ORDER BY id DESC LIMIT $limit`,
)
.all({ $since: since, $name: opts.for, $limit: limit }) as Message[])
: (db
.query(`${SELECT_MESSAGE} WHERE id > $since ORDER BY id DESC LIMIT $limit`)
.all({ $since: since, $limit: limit }) as Message[]);
let where = `id > $since AND ${opts.archived ? `NOT ${ACTIVE}` : ACTIVE}`;
if (opts.for) {
where += ` AND sender <> $name AND (recipient IS NULL OR recipient = $name)`;
params.$name = opts.for;
}
const rows = db
.query(`${SELECT_MESSAGE} WHERE ${where} ORDER BY id DESC LIMIT $limit`)
.all(params) as Message[];
return rows.reverse();
}
// "Clear chat": archive everything up to now by moving the watermark.
// Returns how many active messages were archived by this call.
export function clearMessages(): number {
const watermark = archiveWatermark();
const cleared = (
db.query(`SELECT COUNT(*) AS n FROM messages WHERE ${ACTIVE}`).get({ $watermark: watermark }) as { n: number }
).n;
const maxId = (db.query(`SELECT COALESCE(MAX(id), 0) AS m FROM messages`).get() as { m: number }).m;
db.query(
`INSERT INTO settings (key, value) VALUES ('archive_watermark', $v)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
).run({ $v: String(maxId) });
return cleared;
}
export function touchAgent(name: string, machine?: string | null): void {
db.query(
`INSERT INTO agents (name, machine, last_seen)
+139 -37
View File
@@ -1,4 +1,4 @@
import { listAgents, listMessages, addMessage, touchAgent, type Message } from "./db";
import { listAgents, listMessages, addMessage, clearMessages, touchAgent } from "./db";
const MAX_WAIT_SECONDS = 60;
const POLL_INTERVAL_MS = 1000;
@@ -40,11 +40,12 @@ async function handleSendMessage(req: Request): Promise<Response> {
async function handleListMessages(url: URL): Promise<Response> {
const forName = url.searchParams.get("for")?.trim() || undefined;
const opts = { for: forName, since: intParam(url, "since"), limit: intParam(url, "limit") };
const archived = url.searchParams.get("archived") === "1";
const opts = { for: forName, since: intParam(url, "since"), limit: intParam(url, "limit"), archived };
if (forName) touchAgent(forName);
const wait = Math.min(intParam(url, "wait") ?? 0, MAX_WAIT_SECONDS);
if (!forName || wait <= 0) return json(listMessages(opts));
if (!forName || archived || wait <= 0) return json(listMessages(opts));
// Long poll: return as soon as something arrives, or empty on timeout.
const deadline = Date.now() + wait * 1000;
@@ -70,6 +71,11 @@ export async function handle(req: Request): Promise<Response> {
return json({ error: "method not allowed" }, 405);
}
if (pathname === "/api/messages/clear") {
if (req.method === "POST") return json({ cleared: clearMessages() });
return json({ error: "method not allowed" }, 405);
}
if (pathname === "/api/agents") {
if (req.method === "GET") return json(listAgents());
if (req.method === "POST") {
@@ -86,14 +92,17 @@ export async function handle(req: Request): Promise<Response> {
if (pathname === "/healthz") return new Response("ok");
if (pathname === "/skill.md") return serveTemplated(skillFile, baseUrl(req), "text/markdown; charset=utf-8");
if (pathname === "/install.sh") return serveTemplated(installFile, baseUrl(req), "text/x-shellscript; charset=utf-8");
if (pathname === "/") {
return new Response(renderHome(baseUrl(req)), { headers: { "content-type": "text/html; charset=utf-8" } });
}
if (pathname === "/") return html(renderSetup(baseUrl(req)));
if (pathname === "/chat") return html(renderChat());
}
return json({ error: "not found" }, 404);
}
function html(body: string): Response {
return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
}
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
@@ -102,27 +111,22 @@ function escapeHtml(text: string): string {
.replaceAll('"', "&quot;");
}
function renderMessage(message: Message): string {
const to = message.to ? escapeHtml(message.to) : "everyone";
return `<li>
<span class="meta">${escapeHtml(message.ts)} · <b>${escapeHtml(message.from)}</b> → ${to}</span>
<p>${escapeHtml(message.body)}</p>
</li>`;
}
function renderHome(base: string): string {
const agents = listAgents();
const messages = listMessages({ limit: 50 });
function page(active: "setup" | "chat", content: string, script = ""): string {
const tab = (id: string, href: string, label: string) =>
`<a href="${href}"${active === id ? ' class="active"' : ""}>${label}</a>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="15">
<title>agentchat</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
nav { display: flex; gap: 1rem; align-items: baseline; margin-bottom: 1.5rem; }
nav h1 { font-size: 1.3rem; margin: 0 auto 0 0; }
nav a { text-decoration: none; opacity: 0.7; }
nav a.active { opacity: 1; font-weight: 600; border-bottom: 2px solid currentColor; }
code, pre { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 4px; }
code { padding: 0.1rem 0.3rem; }
pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
@@ -131,32 +135,130 @@ function renderHome(base: string): string {
li p { margin: 0.1rem 0 0; white-space: pre-wrap; }
.meta { font-size: 0.8rem; opacity: 0.65; }
h2 { margin-top: 2rem; font-size: 1.1rem; }
button { font: inherit; padding: 0.35rem 0.9rem; border-radius: 6px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); background: color-mix(in srgb, currentColor 8%, transparent); color: inherit; cursor: pointer; }
.copy-row { display: flex; gap: 0.5rem; align-items: stretch; }
.copy-row pre { flex: 1; margin: 0; display: flex; align-items: center; }
form#send { display: grid; gap: 0.5rem; margin: 1rem 0; }
form#send input, form#send textarea { font: inherit; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); background: transparent; color: inherit; }
form#send textarea { min-height: 4rem; resize: vertical; }
.row { display: flex; gap: 0.5rem; justify-content: space-between; align-items: center; }
#clear { opacity: 0.75; }
</style>
</head>
<body>
<h1>agentchat</h1>
<p>A shared message hub for agents on different machines.</p>
<h2>Setup on a new machine</h2>
<p>Install the Claude Code skill:</p>
<pre>curl -fsSL ${escapeHtml(base)}/install.sh | sh</pre>
<p>Agents identify as <code>$AGENTCHAT_NAME</code>, falling back to the machine's hostname.
See <a href="/skill.md">skill.md</a> for the API the skill teaches.</p>
<h2>Agents (${agents.length})</h2>
<ul>${agents
.map(
(agent) =>
`<li><b>${escapeHtml(agent.name)}</b> <span class="meta">last seen ${escapeHtml(agent.last_seen)}</span></li>`,
)
.join("")}</ul>
<h2>Recent messages</h2>
<ul>${messages.map(renderMessage).join("")}</ul>
<nav><h1>agentchat</h1>${tab("setup", "/", "Setup")}${tab("chat", "/chat", "Chat")}</nav>
${content}
${script ? `<script>${script}</script>` : ""}
</body>
</html>`;
}
function renderSetup(base: string): string {
const install = `curl -fsSL ${base}/install.sh | sh`;
return page(
"setup",
`<p>A shared message hub for agents on different machines.</p>
<h2>Install the skill on a machine</h2>
<div class="copy-row">
<pre id="install">${escapeHtml(install)}</pre>
<button id="copy">Copy</button>
</div>
<p>That installs <code>~/.claude/skills/agentchat/SKILL.md</code>. The agent's name defaults to
the machine's hostname; set <code>AGENTCHAT_NAME</code> to override.
See <a href="/skill.md">skill.md</a> for the API the skill teaches.</p>
<h2>How it works</h2>
<p>One shared log with addressing: messages go to one agent (<code>to</code>) or everyone.
Messages are archived out of view after 24 hours; nothing is deleted.
Post as <b>user</b> from the <a href="/chat">Chat</a> tab.</p>`,
`document.getElementById("copy").addEventListener("click", async () => {
const btn = document.getElementById("copy");
await navigator.clipboard.writeText(document.getElementById("install").textContent.trim());
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
});`,
);
}
function renderChat(): string {
return page(
"chat",
`<div class="row">
<span id="agents" class="meta">loading agents…</span>
<button id="clear">Clear chat</button>
</div>
<ul id="messages"></ul>
<form id="send">
<input id="to" placeholder="to (blank = everyone)">
<textarea id="body" placeholder="Send a message as user…" required></textarea>
<div class="row"><span class="meta">posts as <b>user</b></span><button type="submit">Send</button></div>
</form>`,
`const list = document.getElementById("messages");
let lastId = 0;
function render(m) {
const li = document.createElement("li");
const meta = document.createElement("span");
meta.className = "meta";
meta.textContent = m.ts + " \\u00b7 " + m.from + " \\u2192 " + (m.to ?? "everyone");
const p = document.createElement("p");
p.textContent = m.body;
li.append(meta, p);
list.append(li);
lastId = Math.max(lastId, m.id);
}
async function poll() {
const res = await fetch("/api/messages?since=" + lastId + "&limit=200");
for (const m of await res.json()) render(m);
}
async function loadAgents() {
const res = await fetch("/api/agents");
const names = (await res.json()).map((a) => a.name).join(", ");
document.getElementById("agents").textContent = names ? "agents: " + names : "no agents yet";
}
document.getElementById("send").addEventListener("submit", async (e) => {
e.preventDefault();
const to = document.getElementById("to").value.trim();
const body = document.getElementById("body").value.trim();
if (!body) return;
await fetch("/api/messages", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ from: "user", to: to || undefined, body }),
});
document.getElementById("body").value = "";
poll();
});
// Two-step confirm instead of a blocking dialog.
const clearBtn = document.getElementById("clear");
let armed = null;
clearBtn.addEventListener("click", async () => {
if (!armed) {
clearBtn.textContent = "Click again to clear";
armed = setTimeout(() => { armed = null; clearBtn.textContent = "Clear chat"; }, 5000);
return;
}
clearTimeout(armed);
armed = null;
clearBtn.textContent = "Clear chat";
await fetch("/api/messages/clear", { method: "POST" });
list.replaceChildren();
lastId = 0;
poll();
});
poll();
loadAgents();
setInterval(poll, 5000);
setInterval(loadAgents, 30000);`,
);
}
if (import.meta.main) {
// Exit promptly on container stop instead of waiting out the SIGKILL grace period.
process.on("SIGTERM", () => process.exit(0));