Add web chat tab, 24h archive policy, and clear-chat
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:
@@ -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
@@ -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("&", "&")
|
||||
@@ -102,27 +111,22 @@ function escapeHtml(text: string): string {
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user