diff --git a/docs/api-reference.md b/docs/api-reference.md index 1c335683..324d4207 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -668,7 +668,8 @@ detached. By default inboxes live in memory and a server restart empties them. The synced setting `agentInboxPersistEnabled` (default off; Settings β†’ Cross-session features) writes them to `agent-inbox.json` in the data dir (mode `0600`, tmp file + rename) and restores them at boot. Inboxes of sessions that did not come back -are pruned only after the session restore succeeded. Switching the setting off +are pruned only after the session restore succeeded; a session on the +reboot-restore offer keeps its inbox, so a rebuild finds its mail. Switching the setting off deletes the file and keeps what is in memory. The file is never read by `/api/search`, and the file viewer refuses it. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index b1d062fa..eea27701 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -700,7 +700,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock. -⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`. +⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`. ### Voice dictation via Claude diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 3d976294..6b2b337f 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -47,6 +47,9 @@ codeman agent read "$SID" --tail 3000 # terminal tail, ANSI codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact +codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane +codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new +codeman agent ack # remove what you handled (read never removes) codeman agent rm "$SID" # any session except this one ``` @@ -56,6 +59,14 @@ codeman agent rm "$SID" # any session except t starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`. - **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own prompt is output too, so ask for the marker in halves and wait on the joined form. +- **`post`, `inbox`, `ack`** are the agent inbox: a message for another session that starts + no turn. `post` stores ONE quoted argument (multi-line is fine, so `"$(cat task.md)"` works) + and types nothing into the pane. `inbox` reads this session's mail, or peeks at another's + with an id, and removes nothing; `ack …` removes what you handled. `inbox --wait` + blocks while nothing is new, so mail you read but did not ack does not wake it again. + `ls` shows each session's mail in an INBOX column. Mail lives in memory unless + **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), + and goes when its session is deleted. - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 89e09246..27a2244b 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -89,6 +89,13 @@ way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See This section also holds background-agent tracking, including whether to track agents for every session or only the active tab. +**Keep agent inbox across restarts** (under **Cross-session features**, synced, off by +default) saves the pending `codeman agent post` messages to `agent-inbox.json` in the data +dir, readable by you only, so they survive a server restart. Off, the agent inbox lives in +memory and a restart empties it; switching it off deletes the file and keeps what is in +memory. Messages expire 12 hours after they were posted either way. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + ### Appearance | Setting | Notes | diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 8bd51e96..6a5fad47 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -191,6 +191,22 @@ export function stripAnsi(text: string): string { return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, ''); } +/** + * Inbox message text for a terminal. Another session wrote it, so an escape sequence + * in it would otherwise reach the reader's terminal (retitle it, or move the cursor + * over the `from` line above it). ANSI sequences go as in `read --tail`, and every + * control character left (C0 other than newline and tab, DEL, C1) is shown as `\xHH`, + * so a bare `\r` or backspace cannot overwrite the header either. A CRLF line end + * stays a line end. `--json` prints the text as stored. + */ +export function terminalSafeText(text: string): string { + return ( + stripAnsi(text.replace(/\r\n/g, '\n')) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`) + ); +} + /** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */ export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number { if (raw === undefined) return fallback; @@ -950,7 +966,7 @@ export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions) 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); + deps.io.out(terminalSafeText(m.text)); } if (gone) { deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`)); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 9f90b1cc..45a4d24f 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -65,6 +65,7 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js'; import { agentInboxPersistence } from '../agent-inbox-persistence.js'; +import { agentInbox } from '../agent-inbox.js'; /** * Defaults for `POST /api/deepseek/install-profile`. @@ -802,6 +803,9 @@ export function registerSystemRoutes( const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); + // A detached session kept its agent inbox; removing its record is the delete, + // so the mail goes with it (as on the persisted-only DELETE /api/sessions/:id). + agentInbox.drop(s.id); } return { cleanedSessions: result.count }; }); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a240e51e..b8db099e 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -23,6 +23,7 @@ import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; +import { MAX_MESSAGE_BYTES } from './agent-inbox.js'; import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js'; import type { SessionMode } from '../types.js'; import { isAdvisorModel } from '../types/session.js'; @@ -1857,7 +1858,7 @@ export const InboxPostSchema = z text: z .string() .min(1, 'text must not be empty') - .max(4 * 16_384), + .max(4 * MAX_MESSAGE_BYTES), }) .strict(); diff --git a/src/web/server.ts b/src/web/server.ts index bad6b2f6..d9b45404 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -4036,8 +4036,15 @@ export class WebServer extends EventEmitter { // Same gate as the tab layouts: only a restore that succeeded may decide which // inboxes are orphans. A failed one returns above and leaves every message alone. // Kept: live sessions plus every record state.json still holds (a session that - // was not re-adopted this boot but is kept for recovery keeps its mail). - const keep = new Set([...this.sessions.keys(), ...Object.keys(this.store.getSessions())]); + // was not re-adopted this boot but is kept for recovery keeps its mail), plus the + // sessions on the reboot-restore offer. `cleanupStaleSessions()` above has already + // pruned their records, and a rebuild reuses the old id, so without them a restored + // session would come back to an empty inbox. The 12 h TTL bounds what is never rebuilt. + const keep = new Set([ + ...this.sessions.keys(), + ...Object.keys(this.store.getSessions()), + ...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId), + ]); const pruned = agentInbox.pruneExcept(keep); if (pruned > 0) console.log(`[AgentInbox] dropped ${pruned} inbox(es) of sessions that did not come back`); } @@ -4217,9 +4224,10 @@ export class WebServer extends EventEmitter { approvalInbox.stop(); this.keepWarmService.stop(); // A post within the debounce window before shutdown would otherwise not reach disk. + // Flushed again after `app.close()` below, for a post that lands in between. agentInboxPersistence.flush(); // Same reason as `cancelEverything` above: an in-flight wake is awaited by a request, - // and `app.close()` (the last line of this method) does not abort in-flight requests β€” + // and `app.close()` (at the end of this method) does not abort in-flight requests, // so without this a restart during a wake waits out the readiness poll. this.remoteWake?.stop(); @@ -4360,7 +4368,13 @@ export class WebServer extends EventEmitter { // Dispose push store (flush pending saves) this.pushStore.dispose(); - await this.app.close(); + try { + await this.app.close(); + } finally { + // The routes answer until `app.close()` returns, so a post after the flush above + // would otherwise sit in an unref'd debounce timer and never reach disk. + agentInboxPersistence.flush(); + } } } diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index 44674248..6cb416cd 100644 --- a/test/agent-inbox-server-wiring.test.ts +++ b/test/agent-inbox-server-wiring.test.ts @@ -4,7 +4,9 @@ * - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`) * keep it, because a detached session comes back. * - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps - * every session state.json still holds. A failed restore leaves all mail alone. + * every session state.json still holds plus every session on the reboot-restore + * offer (its record is already pruned, and a rebuild reuses the id). A failed + * restore leaves all mail alone. * * Uses a bare `WebServer.prototype` object with just the fields these paths touch, * like tab-layout-restore-gate.test.ts. @@ -12,6 +14,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { agentInbox } from '../src/web/agent-inbox.js'; +import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js'; +import type { RebootRestoreEntry } from '../src/reboot-restore.js'; +import type { SessionState } from '../src/types.js'; type CleanupServer = { _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise; @@ -48,9 +53,23 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe return server; } +/** A reboot-restore offer entry; the prune reads only its `sessionId`. */ +function offerEntry(sessionId: string): RebootRestoreEntry { + return { + sessionId, + workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}`, + mode: 'claude', + resumeConversationId: `conv-${sessionId}`, + state: { id: sessionId, workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}` } as SessionState, + }; +} + describe('agent inbox server wiring', () => { beforeEach(() => agentInbox.resetForTests()); - afterEach(() => agentInbox.resetForTests()); + afterEach(() => { + agentInbox.resetForTests(); + rebootRestoreRegistry.reset(); + }); it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => { const server = bareServer(); @@ -77,4 +96,17 @@ describe('agent inbox server wiring', () => { expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1); expect(agentInbox.pendingCount('orphan')).toBe(0); }); + + it('a successful restore keeps the inbox of a session on the reboot-restore offer', async () => { + // After a host reboot the dead session is in neither `this.sessions` nor state.json + // (cleanupStaleSessions already pruned its record), only in the offer, and the + // rebuild reuses its id, so its mail must still be there when it comes back. + const server = bareServer(); + rebootRestoreRegistry.set([offerEntry('on-offer')]); + for (const id of ['on-offer', 'orphan']) agentInbox.post(id, null, 'm'); + await server.finalizeRestoredState(true); + expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['on-offer']); + expect(agentInbox.pendingCount('on-offer')).toBe(1); + expect(agentInbox.pendingCount('orphan')).toBe(0); + }); }); diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts index 5ec13474..c6331aad 100644 --- a/test/cli-agent.test.ts +++ b/test/cli-agent.test.ts @@ -40,6 +40,7 @@ import { registerAgentCommands, resolveAgentContext, stripAnsi, + terminalSafeText, waitExitCode, type AgentContext, type AgentDeps, @@ -707,6 +708,35 @@ describe('agent inbox verbs', () => { expect(deps.err.join('')).toMatch(/codeman agent ack /); }); + it('inbox prints message text with escape sequences and control characters neutralized', async () => { + const hostile = { + ...MSG, + text: 'title \x1b]0;pwned\x07ok\r\nred \x1b[31mtext\x1b[0m\rover\bwrite\ttab\x9b2J\nend', + }; + const deps = fakeDeps([ok({ receiver: true, messages: [hostile], pending: 1, unseen: 0 })]); + expect(await agentInboxRead(deps, {})).toBe(EXIT.ok); + // out[0] is the `── from …` header (styled by the palette), out[1] the text. + expect(deps.out).toHaveLength(2); + expect(deps.out[1]).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/); + expect(deps.out[1]).toBe('title ok\nred text\\x0dover\\x08write\ttab\\x9b2J\nend'); + }); + + it('terminalSafeText keeps newline and tab and escapes every other control', () => { + expect(terminalSafeText('a\nb\tc')).toBe('a\nb\tc'); + expect(terminalSafeText('a\r\nb')).toBe('a\nb'); + expect(terminalSafeText('\x1b[2J\x1bc\x00\x7f')).toBe('\\x1bc\\x00\\x7f'); + expect(terminalSafeText('ΓΌnΓ―cΓΆdΓ© βœ“')).toBe('ΓΌnΓ―cΓΆdΓ© βœ“'); + }); + + it('inbox --json hands the text out as stored', async () => { + const raw = { ...MSG, text: 'red \x1b[31mtext' }; + const deps = fakeDeps([ok({ receiver: true, messages: [raw], pending: 1, unseen: 0 })], true); + await agentInboxRead(deps, {}); + expect((JSON.parse(deps.out.join('')) as { messages: Array<{ text: string }> }).messages[0].text).toBe( + 'red \x1b[31mtext' + ); + }); + it('inbox 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); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 0cd44d47..3ac9baca 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; +import { agentInbox } from '../../src/web/agent-inbox.js'; // ── Mocks ────────────────────────────────────────────────────────── @@ -343,6 +344,29 @@ describe('system-routes', () => { expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set()); expect(runStaleSessionCleanup).toHaveBeenCalledOnce(); }); + + it('drops the agent inbox of every record it removes, and only those', async () => { + agentInbox.resetForTests(); + agentInbox.post('stale-detached', null, 'mail for a detached session'); + agentInbox.post('still-kept', null, 'mail for a session that stays'); + harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn( + async (_activeIds: Set, action: (ids: ReadonlySet) => unknown) => + action(new Set(['stale-detached'])) + ); + vi.mocked(harness.ctx.store.cleanupSessionsByIds).mockReturnValueOnce({ + count: 1, + cleaned: [{ id: 'stale-detached', name: 'w9-gone' }], + }); + try { + const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).cleanedSessions).toBe(1); + expect(agentInbox.pendingCount('stale-detached')).toBe(0); + expect(agentInbox.pendingCount('still-kept')).toBe(1); + } finally { + agentInbox.resetForTests(); + } + }); }); // ========== GET /api/subagents ==========