mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(agent): per-session agent inbox (discussion #558)
A coordinator can leave text for a worker without typing into its pane. - Store (src/web/agent-inbox.ts): read until ack by id, seen only for the receiver, 16 KiB/message, 512 KiB + 200 messages per inbox, full = 409 CONFLICT (never drops the oldest), 12 h TTL from each message's createdAt. - Routes (routes/inbox-routes.ts): POST/GET /api/sessions/:id/inbox (?wait= parks while nothing is unseen), POST .../inbox/ack (receiver only, 403 otherwise), GET /api/agent-inbox/summary (owner-scoped counts, not in toState()). Caller = resolveParentSessionId(). - Inbox waiters are the wait registry's third kind: same caps, ended by cancelAll/cancelEverything. - Opt-in snapshot (agentInboxPersistEnabled, synced, default off): agent-inbox.json via dataPath(), 0600 tmp+rename, off deletes it, boot prune only after a successful restore. Dropped on delete, kept on detach. - CLI: codeman agent post | inbox [--wait] | ack, INBOX column in ls. - waitCapacityResponse/abortOnClientHangUp moved to route-helpers.ts unchanged. - Docs: api-reference, README, CLAUDE.md, architecture-invariants, skill + plugin mirror. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
002de32a08
commit
c6dfaa2cac
34 files changed
+2308
-83
No files matched your search
+234
-13
@@ -3,7 +3,8 @@
|
||||
* inside a Codeman session, in every CLI mode.
|
||||
*
|
||||
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
|
||||
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
|
||||
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the
|
||||
* agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It
|
||||
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
|
||||
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
|
||||
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
|
||||
@@ -416,15 +417,55 @@ export async function resolveSessionId(
|
||||
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
|
||||
}
|
||||
|
||||
/** `agent ls` — every session the caller can see, self marked. */
|
||||
/** One session's row in `GET /api/agent-inbox/summary`. */
|
||||
export interface InboxSummaryRow {
|
||||
pending: number;
|
||||
unseen: number;
|
||||
oldestUnseenAt: number | null;
|
||||
parked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbox summary, or undefined when it cannot be had (a server without the route,
|
||||
* a transient error). `ls` must still list sessions then, so this never fails the verb.
|
||||
*/
|
||||
async function fetchInboxSummary(deps: AgentDeps): Promise<Record<string, InboxSummaryRow> | undefined> {
|
||||
try {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/agent-inbox/summary' });
|
||||
if (!res.json?.success) return undefined;
|
||||
return (res.json.data as { sessions?: Record<string, InboxSummaryRow> } | undefined)?.sessions ?? {};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The INBOX cell of `ls`: `3 (1 new)`, `parked`, both, or empty. */
|
||||
export function inboxCell(row: InboxSummaryRow | undefined): string {
|
||||
if (!row) return '';
|
||||
const parts: string[] = [];
|
||||
if (row.pending > 0) parts.push(row.unseen > 0 ? `${row.pending} (${row.unseen} new)` : String(row.pending));
|
||||
if (row.parked) parts.push('parked');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/** `agent ls` — every session the caller can see, self marked, with its inbox counts. */
|
||||
export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||||
const [res, inbox] = await Promise.all([
|
||||
deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }),
|
||||
fetchInboxSummary(deps),
|
||||
]);
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
|
||||
if (deps.json) {
|
||||
emitJson(
|
||||
deps,
|
||||
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
|
||||
sessions.map((s) => ({
|
||||
...s,
|
||||
self: isSelfSession(deps.ctx.selfId, s.id),
|
||||
// null = empty inbox and not parked. The key is absent when the server did
|
||||
// not answer the summary, so "no data" never reads as "no mail".
|
||||
...(inbox ? { inbox: inbox[s.id] ?? null } : {}),
|
||||
}))
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
@@ -432,17 +473,22 @@ export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
deps.io.out(palette.muted('(no sessions)'));
|
||||
return EXIT.ok;
|
||||
}
|
||||
const rows = sessions.map((s) => [
|
||||
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
|
||||
s.id.slice(0, 8),
|
||||
s.mode ?? '?',
|
||||
s.status ?? '?',
|
||||
s.name || s.workingDir || '',
|
||||
]);
|
||||
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
|
||||
const rows = sessions.map((s) => {
|
||||
const row = [isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ', s.id.slice(0, 8), s.mode ?? '?', s.status ?? '?'];
|
||||
if (inbox) row.push(inboxCell(inbox[s.id]));
|
||||
row.push(s.name || s.workingDir || '');
|
||||
return row;
|
||||
});
|
||||
const header = inbox ? [' ', 'ID', 'MODE', 'STATUS', 'INBOX', 'NAME'] : [' ', 'ID', 'MODE', 'STATUS', 'NAME'];
|
||||
deps.io.out(table([header, ...rows], { gap: 2 }));
|
||||
deps.io.out(
|
||||
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
|
||||
);
|
||||
if (inbox) {
|
||||
deps.io.out(
|
||||
palette.muted('INBOX: pending (unseen), parked = the session is waiting on `agent inbox --wait` right now.')
|
||||
);
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
@@ -790,6 +836,140 @@ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Agent inbox
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A message as `GET …/inbox` hands it out. */
|
||||
export interface InboxMessageRow {
|
||||
id: string;
|
||||
from: string | null;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
seen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message `post` was given, which must be ONE argument, as with `send`: several
|
||||
* would be an unquoted `$(cat notes.md)` split by the shell, and joining the pieces
|
||||
* with spaces loses its newlines. Unlike `send`, newlines inside the one argument are
|
||||
* fine: the inbox stores text as data and never types it.
|
||||
*/
|
||||
export function postTextFromArgs(words: readonly string[]): { text: string } | { error: string } {
|
||||
if (words.length === 1) return { text: words[0] };
|
||||
return { error: `refusing: the message must be ONE argument, got ${words.length} — quote it` };
|
||||
}
|
||||
|
||||
/** `agent post` — store a message in another session's inbox. Never types into its pane. */
|
||||
export async function agentPost(deps: AgentDeps, options: { id: string; text: string }): Promise<number> {
|
||||
if (options.text.length === 0) return fail(deps, 'refusing: empty message', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
|
||||
body: { text: options.text },
|
||||
});
|
||||
// 409 = the receiver's inbox is full; nothing was stored. Passed through as an error.
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as { message?: InboxMessageRow; pending?: number; unseen?: number } | undefined;
|
||||
if (deps.json) emitJson(deps, data ?? {});
|
||||
else
|
||||
deps.io.out(
|
||||
palette.ok(
|
||||
`${GLYPH.ok} posted to ${target.id.slice(0, 8)} (${data?.pending ?? '?'} pending, ${data?.unseen ?? '?'} unseen)`
|
||||
)
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
export interface InboxReadOptions {
|
||||
/** Another session's inbox (a peek); omitted = this session's own. */
|
||||
id?: string;
|
||||
/** Long-poll budget in ms; the read parks while nothing is unseen. */
|
||||
waitMs?: number;
|
||||
}
|
||||
|
||||
/** Relative age for humans: `12s`, `4m`, `3h`. */
|
||||
function age(createdAt: number, now: number): string {
|
||||
const s = Math.max(0, Math.round((now - createdAt) / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`;
|
||||
return `${Math.round(s / 3600)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `agent inbox` — read this session's inbox (or peek at another's). Removes nothing:
|
||||
* a message stays until `agent ack <id>`. Only reading your OWN inbox marks messages
|
||||
* seen. Exit 0 with mail, 2 when a `--wait` ran out with nothing unseen, 3 when the
|
||||
* session went away during the wait.
|
||||
*/
|
||||
export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions): Promise<number> {
|
||||
const target = await resolveSessionId(deps, options.id ?? deps.ctx.selfId);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
|
||||
query: { wait: options.waitMs },
|
||||
timeoutMs: options.waitMs !== undefined ? options.waitMs + 10_000 : undefined,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as
|
||||
| {
|
||||
receiver?: boolean;
|
||||
messages?: InboxMessageRow[];
|
||||
pending?: number;
|
||||
unseen?: number;
|
||||
wait?: { timedOut?: boolean; ended?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
const messages = data?.messages ?? [];
|
||||
const code = data?.wait?.ended ? EXIT.dead : data?.wait?.timedOut ? EXIT.timeout : EXIT.ok;
|
||||
if (deps.json) {
|
||||
emitJson(deps, data ?? {});
|
||||
return code;
|
||||
}
|
||||
const now = (deps.now ?? Date.now)();
|
||||
for (const m of messages) {
|
||||
const sender = m.from ? m.from.slice(0, 8) : 'user';
|
||||
const tag = m.seen ? palette.muted('seen') : palette.emph('new');
|
||||
deps.io.out(palette.muted(`── ${m.id} from ${sender}, ${age(m.createdAt, now)} ago, `) + tag);
|
||||
deps.io.out(m.text);
|
||||
}
|
||||
if (data?.wait?.ended) {
|
||||
deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`));
|
||||
} else if (messages.length === 0) {
|
||||
deps.io.err(palette.muted(data?.wait?.timedOut ? '(no mail; wait timed out, exit 2)' : '(no mail)'));
|
||||
} else if (data?.receiver) {
|
||||
deps.io.err(
|
||||
palette.muted(
|
||||
`${messages.length} pending. Nothing is removed by reading: when done, \`codeman agent ack <id>…\` (full ids above).`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
deps.io.err(palette.muted(`${messages.length} pending (peek: nothing was marked seen).`));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** `agent ack` — remove messages from THIS session's inbox by id. Idempotent. */
|
||||
export async function agentAck(deps: AgentDeps, options: { ids: string[] }): Promise<number> {
|
||||
const ids = options.ids.map((id) => id.trim()).filter(Boolean);
|
||||
if (ids.length === 0) return fail(deps, 'refusing: no message ids', EXIT.refused);
|
||||
const self = await resolveSessionId(deps, deps.ctx.selfId);
|
||||
if ('error' in self) return fail(deps, self.error, self.code);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(self.id)}/inbox/ack`,
|
||||
body: { ids },
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as { removed?: number; pending?: number } | undefined;
|
||||
if (deps.json) emitJson(deps, data ?? {});
|
||||
else deps.io.out(palette.ok(`${GLYPH.ok} acked ${data?.removed ?? 0} (${data?.pending ?? '?'} still pending)`));
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Commander wiring
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -833,7 +1013,9 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P
|
||||
export function registerAgentCommands(program: Command): Command {
|
||||
const agent = program
|
||||
.command('agent')
|
||||
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
|
||||
.description(
|
||||
'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack'
|
||||
);
|
||||
|
||||
agent
|
||||
.command('ls')
|
||||
@@ -977,6 +1159,45 @@ export function registerAgentCommands(program: Command): Command {
|
||||
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('post <id> <text...>')
|
||||
.description(
|
||||
"Leave a message in another session's inbox (ONE quoted argument; multi-line is fine). Nothing is typed into its pane: it reads with `agent inbox`"
|
||||
)
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, words: string[], options: { json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => {
|
||||
const message = postTextFromArgs(words);
|
||||
if ('error' in message) return Promise.resolve(fail(deps, message.error, EXIT.refused));
|
||||
return agentPost(deps, { id, text: message.text });
|
||||
})
|
||||
);
|
||||
|
||||
agent
|
||||
.command('inbox [id]')
|
||||
.description(
|
||||
"Read this session's inbox (or peek at another's with [id]). Reading removes nothing; `agent ack` does. --wait blocks while nothing is new: exit 2 on timeout"
|
||||
)
|
||||
.option('-w, --wait', 'Block until a new (unseen) message arrives')
|
||||
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string | undefined, options: { wait?: boolean; timeout?: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentInboxRead(deps, {
|
||||
id,
|
||||
waitMs: options.wait ? parsePositiveInt(options.timeout, DEFAULT_WAIT_MS) : undefined,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('ack <ids...>')
|
||||
.description("Remove messages from this session's inbox by id, once handled (unknown ids are not an error)")
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((ids: string[], options: { json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => agentAck(deps, { ids }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('rm <id>')
|
||||
.description('Delete any session except this one (refuses your own id)')
|
||||
|
||||
Reference in new issue
Block a user