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:
RandalixandClaude Opus 5.5 committed 2026-10-23 12:45:48 +02:00
1 parent 002de32a08
commit c6dfaa2cac
34 files changed
+2308 -83

No files matched your search

+146
View File
@@ -13,8 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentAck,
agentInboxRead,
agentInterrupt,
agentLs,
agentPost,
agentRead,
agentRm,
agentSend,
@@ -28,10 +31,12 @@ import {
deleteRefusal,
describeFailure,
httpRequest,
inboxCell,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
postTextFromArgs,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
@@ -607,6 +612,147 @@ describe('session id prefixes', () => {
});
});
describe('agent inbox verbs', () => {
const MSG = {
id: 'm-1',
from: OTHER,
text: 'line one\nline two',
createdAt: 1_700_000_000_000 - 90_000,
seen: false,
};
it('ls merges the inbox summary into an INBOX column', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({
sessions: {
[SELF]: { pending: 2, unseen: 1, oldestUnseenAt: 1, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
},
})
: ok([
{ id: SELF, mode: 'claude', status: 'busy', name: 'me' },
{ id: OTHER, mode: 'codex', status: 'idle', name: 'worker' },
])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/ID\s+MODE\s+STATUS\s+INBOX\s+NAME/);
expect(text).toMatch(/058ee7b5\s+claude\s+busy\s+2 \(1 new\)\s+me/);
expect(text).toMatch(/94990c6d\s+codex\s+idle\s+parked\s+worker/);
});
it('ls --json carries each session’s inbox row (null when it has none)', async () => {
const deps = fakeDeps(
(o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({ sessions: { [OTHER]: { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false } } })
: ok([{ id: SELF }, { id: OTHER }]),
true
);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; inbox: unknown }>;
expect(parsed.map((s) => s.inbox)).toEqual([null, { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false }]);
});
it('ls still lists sessions when the server has no summary route', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary' ? apiError(404, 'NOT_FOUND', 'Route not found') : ok([{ id: OTHER }])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
expect(deps.out.join('\n')).not.toContain('INBOX');
});
it('inboxCell', () => {
expect(inboxCell(undefined)).toBe('');
expect(inboxCell({ pending: 3, unseen: 0, oldestUnseenAt: null, parked: false })).toBe('3');
expect(inboxCell({ pending: 3, unseen: 2, oldestUnseenAt: 1, parked: true })).toBe('3 (2 new), parked');
});
it('post sends the text as data to the resolved session and never to /input', async () => {
const deps = fakeDeps([ok({ message: MSG, pending: 1, unseen: 1 })]);
expect(await agentPost(deps, { id: OTHER, text: 'line one\nline two' })).toBe(EXIT.ok);
expect(deps.calls).toEqual([
expect.objectContaining({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/inbox`,
body: { text: 'line one\nline two' },
}),
]);
expect(deps.out.join('')).toMatch(/posted to 94990c6d \(1 pending, 1 unseen\)/);
});
it('post passes a full inbox (409) through as an error', async () => {
const deps = fakeDeps([apiError(409, 'CONFLICT', 'Inbox of x is full (200 messages).')]);
expect(await agentPost(deps, { id: OTHER, text: 'x' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/CONFLICT: Inbox of x is full/);
});
it('post takes ONE argument and refuses an empty one before any request', async () => {
expect(postTextFromArgs(['a\nb'])).toEqual({ text: 'a\nb' });
expect(postTextFromArgs(['a', 'b'])).toEqual({ error: expect.stringMatching(/ONE argument, got 2/) });
const deps = fakeDeps([]);
expect(await agentPost(deps, { id: OTHER, text: '' })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('inbox reads this session’s own inbox by default and prints full ids plus the ack hint', async () => {
const deps = fakeDeps([ok({ receiver: true, messages: [MSG], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'GET', path: `/api/v1/sessions/${SELF}/inbox` });
expect(deps.calls[0].query).toEqual({ wait: undefined });
const text = deps.out.join('\n');
expect(text).toMatch(/── m-1 from 94990c6d, 2m ago, new/);
expect(text).toContain('line one\nline two');
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
});
it('inbox <id> is a peek and says so', async () => {
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
expect(deps.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/inbox`);
expect(deps.err.join('')).toMatch(/peek: nothing was marked seen/);
});
it('inbox --wait passes the budget and maps timeout to exit 2, a vanished session to exit 3', async () => {
const timedOut = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { timedOut: true } })]);
expect(await agentInboxRead(timedOut, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(timedOut.calls[0]).toMatchObject({ query: { wait: 30_000 }, timeoutMs: 40_000 });
const ended = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { ended: true } })]);
expect(await agentInboxRead(ended, { waitMs: 30_000 })).toBe(EXIT.dead);
});
it('ack posts the ids to this session’s own inbox', async () => {
const deps = fakeDeps([ok({ removed: 2, pending: 0 })]);
expect(await agentAck(deps, { ids: ['m-1', ' m-2 '] })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${SELF}/inbox/ack`,
body: { ids: ['m-1', 'm-2'] },
});
expect(deps.out.join('')).toMatch(/acked 2 \(0 still pending\)/);
});
it('ack refuses without ids and passes a 403 through', async () => {
const none = fakeDeps([]);
expect(await agentAck(none, { ids: [' '] })).toBe(EXIT.refused);
expect(none.calls).toEqual([]);
const forbidden = fakeDeps([apiError(403, 'FORBIDDEN', 'Only x itself can acknowledge its inbox')]);
expect(await agentAck(forbidden, { ids: ['m-1'] })).toBe(EXIT.error);
});
it('none of the inbox verbs writes to a pane', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([{ id: OTHER }]) : ok({ messages: [], pending: 0, unseen: 0 })
);
await agentPost(deps, { id: OTHER, text: 'x' });
await agentInboxRead(deps, {});
await agentInboxRead(deps, { id: OTHER, waitMs: 1000 });
await agentAck(deps, { ids: ['m'] });
for (const call of deps.calls) expect(call.path).not.toMatch(/\/input$/);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;