fix(agent-inbox): review round 1

- codeman agent post: accept text that starts with "-" (markdown from a
  file), --json and -h stay options.
- DELETE /api/sessions/:id on a persisted-only row drops the inbox a detach
  kept.
- inbox --wait: wait.ended is exit 3 only when the session list no longer has
  the session; a restart or detach ending the wait is exit 2 (wait again).
- A read whose client hung up during ?wait= marks nothing seen.
- InboxPostSchema bound is 4x the byte cap, so every oversize message gets
  the store's "post a file path" answer.
- Store: restore sorts oldest first; the TTL sweep checks every message,
  not only the first.
- Docs: admins can act as any session of the receiver's owner (now tested
  for admin and non-admin); a peek with ?wait= does not block; exit codes.
- Tests: partial PUT /api/settings keeps persistence on.

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 c6dfaa2cac
commit a1a33cf050
15 files changed
+209 -31

No files matched your search

+26 -4
View File
@@ -890,6 +890,17 @@ export interface InboxReadOptions {
waitMs?: number;
}
/** True only when the session list was read and does not contain `id`. */
async function sessionIsGone(deps: AgentDeps, id: string): Promise<boolean> {
try {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return false;
return !((res.json.data as SessionRow[] | undefined) ?? []).some((s) => s.id === id);
} catch {
return false;
}
}
/** Relative age for humans: `12s`, `4m`, `3h`. */
function age(createdAt: number, now: number): string {
const s = Math.max(0, Math.round((now - createdAt) / 1000));
@@ -901,8 +912,8 @@ function age(createdAt: number, now: number): string {
/**
* `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.
* seen. Exit 0 with mail, 2 when a `--wait` ran out with nothing unseen or the server
* cut it short (restart, detach), 3 when the session is gone from the list.
*/
export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id ?? deps.ctx.selfId);
@@ -924,7 +935,12 @@ export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions)
}
| undefined;
const messages = data?.messages ?? [];
const code = data?.wait?.ended ? EXIT.dead : data?.wait?.timedOut ? EXIT.timeout : EXIT.ok;
// `ended` is also what a server restart or a detach answers. Exit 3 tells a receiver
// loop to stop, so it needs the session list to confirm the session is gone; while
// it is still listed (or the list cannot be had, as during a restart) the wait was
// only cut short, which is exit 2: loop again.
const gone = data?.wait?.ended ? await sessionIsGone(deps, target.id) : false;
const code = gone ? EXIT.dead : data?.wait?.timedOut || data?.wait?.ended ? EXIT.timeout : EXIT.ok;
if (deps.json) {
emitJson(deps, data ?? {});
return code;
@@ -936,8 +952,10 @@ export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions)
deps.io.out(palette.muted(`── ${m.id} from ${sender}, ${age(m.createdAt, now)} ago, `) + tag);
deps.io.out(m.text);
}
if (data?.wait?.ended) {
if (gone) {
deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`));
} else if (data?.wait?.ended) {
deps.io.err(palette.muted('(the server ended the wait early, e.g. a restart; exit 2, wait again)'));
} 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) {
@@ -1165,6 +1183,10 @@ export function registerAgentCommands(program: Command): Command {
"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')
// A message is often a file (`"$(cat task.md)"`) and markdown starts with `- ` or
// `---` often enough; without this commander takes it for an unknown option.
// `--json` and `-h` are still options: text that is exactly one goes after `--`.
.allowUnknownOption()
.action((id: string, words: string[], options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => {
const message = postTextFromArgs(words);