fix: apply the review items for #690 at merge

Boot prune keeps the inboxes of sessions on the reboot-restore offer (their records are already pruned and a rebuild reuses the id), with a case in test/agent-inbox-server-wiring.test.ts.
docs/wiki: post, inbox and ack in the codeman agent command block of Driving-Codeman-From-An-Agent, and "Keep agent inbox across restarts" in Settings-Reference.
codeman agent inbox prints message text through terminalSafeText (ANSI stripped, other C0, DEL and C1 controls shown as \xHH); --json stays as stored.
Shutdown flushes the inbox snapshot again after app.close(), for a post that lands after the first flush.
POST /api/cleanup-state drops the inboxes of the records it removes.
InboxPostSchema derives its fence from MAX_MESSAGE_BYTES instead of the literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-24 14:15:49 +02:00
1 parent a1a33cf050
commit 9ba24a02df
11 files changed
+150 -10

No files matched your search

+17 -1
View File
@@ -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`));
+4
View File
@@ -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 };
});
+2 -1
View File
@@ -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();
+18 -4
View File
@@ -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();
}
}
}