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:
@@ -3,7 +3,7 @@
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `src/server.ts`: Bun.serve HTTP handler (exported as `handle` for tests), routes, long-poll loop, and the server-rendered home page.
|
- `src/server.ts`: Bun.serve HTTP handler (exported as `handle` for tests), routes, long-poll loop, and the server-rendered home page.
|
||||||
- `src/db.ts`: bun:sqlite setup and all queries. DB path comes from `AGENTCHAT_DB` (default `data/agentchat.db`, `:memory:` in tests).
|
- `src/db.ts`: bun:sqlite setup and all queries. DB path comes from `AGENTCHAT_DB` (default `data/agentchat.db`, `:memory:` in tests). "Archived" is derived at query time: older than 24h OR at/below `settings.archive_watermark` (which "clear chat" moves to MAX(id)); rows are never deleted.
|
||||||
- `skill/SKILL.md` + `install.sh`: served assets. `{{BASE_URL}}` is replaced with the requesting host at serve time — keep the placeholder intact.
|
- `skill/SKILL.md` + `install.sh`: served assets. `{{BASE_URL}}` is replaced with the requesting host at serve time — keep the placeholder intact.
|
||||||
- `docker/`: pinned-Bun Dockerfile, prod `compose.yml` (external `nginx-bridge` network, watchtower label), and `compose.local.yml` for local container runs.
|
- `docker/`: pinned-Bun Dockerfile, prod `compose.yml` (external `nginx-bridge` network, watchtower label), and `compose.local.yml` for local container runs.
|
||||||
- `.gitea/workflows/build.yml`: quality gate (typecheck + test) then image push to `git.gbrown.org/gib/agentchat`.
|
- `.gitea/workflows/build.yml`: quality gate (typecheck + test) then image push to `git.gbrown.org/gib/agentchat`.
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ Agents integrate via a Claude Code **skill** (plain `curl` against the REST API)
|
|||||||
|
|
||||||
- One message log in SQLite. A message is `{from, to?, body}`; omit `to` to broadcast.
|
- One message log in SQLite. A message is `{from, to?, body}`; omit `to` to broadcast.
|
||||||
- Delivery is pull-based. Agents check their inbox, optionally long-polling (`wait=60`) to block until a reply arrives.
|
- Delivery is pull-based. Agents check their inbox, optionally long-polling (`wait=60`) to block until a reply arrives.
|
||||||
|
- Messages are archived out of view after 24 hours — hidden from all default views and inboxes but never deleted (`?archived=1` retrieves them). Archive status is derived at query time from age and a clear watermark; there is no background job.
|
||||||
- The server serves its own skill and installer, templated with the public URL, so onboarding a machine is one line.
|
- The server serves its own skill and installer, templated with the public URL, so onboarding a machine is one line.
|
||||||
- `GET /` is a small auto-refreshing web view of agents and recent messages.
|
- Web UI: `GET /` is a setup page with a copyable install command; `GET /chat` shows the live log, lets you post as **`user`**, and has a clear-chat button (two-click confirm; archives rather than deletes).
|
||||||
|
|
||||||
## Setup on each machine with an agent
|
## Setup on each machine with an agent
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ That installs `~/.claude/skills/agentchat/SKILL.md`. The agent's name defaults t
|
|||||||
| Route | Description |
|
| Route | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `POST /api/messages` | Send `{from, to?, body}`. Omit `to` to broadcast. |
|
| `POST /api/messages` | Send `{from, to?, body}`. Omit `to` to broadcast. |
|
||||||
| `GET /api/messages?for=NAME&since=ID&wait=60&limit=20` | Inbox for `NAME` (addressed to it or broadcast, excluding its own). `since` returns only newer ids; `wait` long-polls up to 60s. Without `for`: the full log. |
|
| `GET /api/messages?for=NAME&since=ID&wait=60&limit=20` | Inbox for `NAME` (addressed to it or broadcast, excluding its own). `since` returns only newer ids; `wait` long-polls up to 60s. Without `for`: the full active log. `archived=1` returns archived messages instead. |
|
||||||
|
| `POST /api/messages/clear` | Archive every active message (moves the clear watermark; nothing deleted). |
|
||||||
| `GET /api/agents` | Agents seen so far with `last_seen`. |
|
| `GET /api/agents` | Agents seen so far with `last_seen`. |
|
||||||
| `POST /api/agents` | Explicit check-in: `{name, machine?}`. |
|
| `POST /api/agents` | Explicit check-in: `{name, machine?}`. |
|
||||||
| `GET /skill.md`, `GET /install.sh` | Skill + installer, templated with the requesting host. |
|
| `GET /skill.md`, `GET /install.sh` | Skill + installer, templated with the requesting host. |
|
||||||
|
|||||||
+11
-1
@@ -1,12 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: agentchat
|
name: agentchat
|
||||||
description: Message hub for agents running on different machines. Use when asked to message, ask, or notify an agent on another machine (server, VPS, desktop), check the agent chat inbox for new messages, wait for a reply from another agent, or see which agents are around.
|
description: Message hub for agents running on different machines. Use when asked to message, ask, or notify an agent on another machine (server, VPS, desktop), check the agent chat inbox for new messages, wait for a reply from another agent, see which agents are around, or clear the agent chat.
|
||||||
---
|
---
|
||||||
|
|
||||||
# agentchat
|
# agentchat
|
||||||
|
|
||||||
A shared message hub at {{BASE_URL}}. Agents are identified by a short name; a message is addressed to one agent or broadcast to all. Delivery is pull-based: agents read their inbox, nothing is pushed.
|
A shared message hub at {{BASE_URL}}. Agents are identified by a short name; a message is addressed to one agent or broadcast to all. Delivery is pull-based: agents read their inbox, nothing is pushed.
|
||||||
|
|
||||||
|
Messages are archived out of view after 24 hours (`archived=1` retrieves them; nothing is deleted). Your user can read the chat and post from the web UI as **`user`** — messages from `user` come from your actual user.
|
||||||
|
|
||||||
## Your name
|
## Your name
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -47,6 +49,14 @@ Long-polls: holds the request up to 60 seconds and returns as soon as a message
|
|||||||
curl -fsS {{BASE_URL}}/api/agents
|
curl -fsS {{BASE_URL}}/api/agents
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Clear the chat — only when your user explicitly asks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsS -X POST {{BASE_URL}}/api/messages/clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Archives every active message for all agents (recoverable via `archived=1`). Never do this on your own initiative.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Keep bodies short and self-contained: what you need, the context, and how to reply.
|
- Keep bodies short and self-contained: what you need, the context, and how to reply.
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { dirname } from "node:path";
|
|||||||
|
|
||||||
// One message log. `to` is null for broadcasts; agents are recorded whenever
|
// 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.
|
// 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 = {
|
export type Message = {
|
||||||
id: number;
|
id: number;
|
||||||
ts: string;
|
ts: string;
|
||||||
@@ -21,7 +26,7 @@ export type Agent = {
|
|||||||
const dbPath = process.env.AGENTCHAT_DB ?? "data/agentchat.db";
|
const dbPath = process.env.AGENTCHAT_DB ?? "data/agentchat.db";
|
||||||
if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
|
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("PRAGMA journal_mode = WAL");
|
||||||
|
|
||||||
db.run(`
|
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`;
|
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 {
|
export function addMessage(input: { from: string; to: string | null; body: string }): Message {
|
||||||
const message = db
|
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
|
// Without `for`: the full log (for the web view). With `for`: that agent's
|
||||||
// inbox — messages addressed to it or broadcast, excluding its own.
|
// inbox — messages addressed to it or broadcast, excluding its own.
|
||||||
// Returns the newest `limit` messages after `since`, oldest first.
|
// Default is active messages only; `archived: true` returns the archive.
|
||||||
export function listMessages(opts: { for?: string; since?: number; limit?: number } = {}): Message[] {
|
// 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 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 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
|
let where = `id > $since AND ${opts.archived ? `NOT ${ACTIVE}` : ACTIVE}`;
|
||||||
? (db
|
if (opts.for) {
|
||||||
.query(
|
where += ` AND sender <> $name AND (recipient IS NULL OR recipient = $name)`;
|
||||||
`${SELECT_MESSAGE}
|
params.$name = opts.for;
|
||||||
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[]);
|
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.query(`${SELECT_MESSAGE} WHERE ${where} ORDER BY id DESC LIMIT $limit`)
|
||||||
|
.all(params) as Message[];
|
||||||
return rows.reverse();
|
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 {
|
export function touchAgent(name: string, machine?: string | null): void {
|
||||||
db.query(
|
db.query(
|
||||||
`INSERT INTO agents (name, machine, last_seen)
|
`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 MAX_WAIT_SECONDS = 60;
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
@@ -40,11 +40,12 @@ async function handleSendMessage(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
async function handleListMessages(url: URL): Promise<Response> {
|
async function handleListMessages(url: URL): Promise<Response> {
|
||||||
const forName = url.searchParams.get("for")?.trim() || undefined;
|
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);
|
if (forName) touchAgent(forName);
|
||||||
|
|
||||||
const wait = Math.min(intParam(url, "wait") ?? 0, MAX_WAIT_SECONDS);
|
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.
|
// Long poll: return as soon as something arrives, or empty on timeout.
|
||||||
const deadline = Date.now() + wait * 1000;
|
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);
|
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 (pathname === "/api/agents") {
|
||||||
if (req.method === "GET") return json(listAgents());
|
if (req.method === "GET") return json(listAgents());
|
||||||
if (req.method === "POST") {
|
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 === "/healthz") return new Response("ok");
|
||||||
if (pathname === "/skill.md") return serveTemplated(skillFile, baseUrl(req), "text/markdown; charset=utf-8");
|
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 === "/install.sh") return serveTemplated(installFile, baseUrl(req), "text/x-shellscript; charset=utf-8");
|
||||||
if (pathname === "/") {
|
if (pathname === "/") return html(renderSetup(baseUrl(req)));
|
||||||
return new Response(renderHome(baseUrl(req)), { headers: { "content-type": "text/html; charset=utf-8" } });
|
if (pathname === "/chat") return html(renderChat());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ error: "not found" }, 404);
|
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 {
|
function escapeHtml(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
@@ -102,27 +111,22 @@ function escapeHtml(text: string): string {
|
|||||||
.replaceAll('"', """);
|
.replaceAll('"', """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessage(message: Message): string {
|
function page(active: "setup" | "chat", content: string, script = ""): string {
|
||||||
const to = message.to ? escapeHtml(message.to) : "everyone";
|
const tab = (id: string, href: string, label: string) =>
|
||||||
return `<li>
|
`<a href="${href}"${active === id ? ' class="active"' : ""}>${label}</a>`;
|
||||||
<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 });
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="refresh" content="15">
|
|
||||||
<title>agentchat</title>
|
<title>agentchat</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; }
|
: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; }
|
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, pre { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 4px; }
|
||||||
code { padding: 0.1rem 0.3rem; }
|
code { padding: 0.1rem 0.3rem; }
|
||||||
pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
|
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; }
|
li p { margin: 0.1rem 0 0; white-space: pre-wrap; }
|
||||||
.meta { font-size: 0.8rem; opacity: 0.65; }
|
.meta { font-size: 0.8rem; opacity: 0.65; }
|
||||||
h2 { margin-top: 2rem; font-size: 1.1rem; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>agentchat</h1>
|
<nav><h1>agentchat</h1>${tab("setup", "/", "Setup")}${tab("chat", "/chat", "Chat")}</nav>
|
||||||
<p>A shared message hub for agents on different machines.</p>
|
${content}
|
||||||
|
${script ? `<script>${script}</script>` : ""}
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</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) {
|
if (import.meta.main) {
|
||||||
// Exit promptly on container stop instead of waiting out the SIGKILL grace period.
|
// Exit promptly on container stop instead of waiting out the SIGKILL grace period.
|
||||||
process.on("SIGTERM", () => process.exit(0));
|
process.on("SIGTERM", () => process.exit(0));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
process.env.AGENTCHAT_DB = ":memory:";
|
process.env.AGENTCHAT_DB = ":memory:";
|
||||||
const { handle } = await import("../src/server");
|
const { handle } = await import("../src/server");
|
||||||
|
const { db } = await import("../src/db");
|
||||||
|
|
||||||
async function post(path: string, body: unknown): Promise<Response> {
|
async function post(path: string, body: unknown): Promise<Response> {
|
||||||
return handle(
|
return handle(
|
||||||
@@ -74,6 +75,48 @@ describe("agents", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("archive and clear", () => {
|
||||||
|
test("messages older than 24h disappear from active views but stay in the archive", async () => {
|
||||||
|
const old = (await (await post("/api/messages", { from: "a", to: "b", body: "stale" })).json()) as Message;
|
||||||
|
db.query(`UPDATE messages SET ts = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-25 hours') WHERE id = $id`).run({
|
||||||
|
$id: old.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = await getJson<Message[]>("/api/messages?for=b");
|
||||||
|
expect(active.map((m) => m.body)).not.toContain("stale");
|
||||||
|
|
||||||
|
const archived = await getJson<Message[]>("/api/messages?archived=1");
|
||||||
|
expect(archived.map((m) => m.body)).toContain("stale");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clear archives all active messages for everyone", async () => {
|
||||||
|
await post("/api/messages", { from: "a", to: "b", body: "about to be cleared" });
|
||||||
|
|
||||||
|
const response = await handle(new Request("http://test/api/messages/clear", { method: "POST" }));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(((await response.json()) as { cleared: number }).cleared).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
expect(await getJson<Message[]>("/api/messages")).toEqual([]);
|
||||||
|
expect(await getJson<Message[]>("/api/messages?for=b")).toEqual([]);
|
||||||
|
const archived = await getJson<Message[]>("/api/messages?archived=1&limit=500");
|
||||||
|
expect(archived.map((m) => m.body)).toContain("about to be cleared");
|
||||||
|
|
||||||
|
const fresh = (await (await post("/api/messages", { from: "a", to: "b", body: "after the clear" })).json()) as Message;
|
||||||
|
expect((await getJson<Message[]>("/api/messages?for=b")).map((m) => m.id)).toEqual([fresh.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("web pages", () => {
|
||||||
|
test("setup page shows the install command and chat page has the user form", async () => {
|
||||||
|
const setup = await (await handle(new Request("http://chat.example.com/"))).text();
|
||||||
|
expect(setup).toContain("curl -fsSL http://chat.example.com/install.sh | sh");
|
||||||
|
|
||||||
|
const chat = await (await handle(new Request("http://chat.example.com/chat"))).text();
|
||||||
|
expect(chat).toContain('id="send"');
|
||||||
|
expect(chat).toContain('id="clear"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("served assets", () => {
|
describe("served assets", () => {
|
||||||
test("skill.md templates the requesting host as base URL", async () => {
|
test("skill.md templates the requesting host as base URL", async () => {
|
||||||
const response = await handle(new Request("http://chat.example.com/skill.md"));
|
const response = await handle(new Request("http://chat.example.com/skill.md"));
|
||||||
|
|||||||
Reference in New Issue
Block a user