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

+9 -4
View File
@@ -228,8 +228,12 @@ export class AgentInbox {
if (!Array.isArray(raw)) continue;
const queue: StoredMessage[] = [];
let bytes = 0;
for (const entry of raw) {
if (!isStoredMessage(entry) || entry.createdAt <= cutoff) continue;
// Oldest first whatever order the file has, so the caps keep the oldest mail,
// which is the rule for posts too.
const entries = raw
.filter((entry): entry is StoredMessage => isStoredMessage(entry) && entry.createdAt > cutoff)
.sort((a, b) => a.createdAt - b.createdAt);
for (const entry of entries) {
const size = Buffer.byteLength(entry.text, 'utf8');
if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES) break;
queue.push({ ...entry });
@@ -253,8 +257,9 @@ export class AgentInbox {
const queue = this.inboxes.get(sessionId);
if (!queue) return [];
const cutoff = this.now() - MESSAGE_TTL_MS;
if (queue.length === 0 || queue[0].createdAt > cutoff) return queue;
// Oldest first, so the expired ones are a prefix.
// Every message, not only queue[0]: a clock stepping back can put an older
// createdAt behind a newer one. At most 200 entries, so the scan is cheap.
if (!queue.some((m) => m.createdAt <= cutoff)) return queue;
const kept = queue.filter((m) => m.createdAt > cutoff);
this.store(sessionId, kept);
this.onChange?.();
+7 -4
View File
@@ -8,8 +8,10 @@
* the caller can see that has the same owner as the receiver (exact id, or a unique
* prefix of at least 8). Anything else is "no session": a post is from the user, and a
* read is a peek. What that buys: in multi-user mode a caller cannot name another
* owner's session. In single-user mode every local process holds the same
* credentials, so the header is a hint the client sets about itself.
* owner's session (an admin, who can see every session, can act as any session of
* the receiver's owner, as admins can everywhere else). In single-user mode every
* local process holds the same credentials, so the header is a hint the client sets
* about itself.
*
* - `POST /api/sessions/:id/inbox` stores a message. `from` is the resolved caller or
* null. A full inbox is 409 CONFLICT; the oldest message is never dropped.
@@ -135,8 +137,9 @@ export function registerInboxRoutes(app: FastifyInstance, ctx: SessionPort): voi
}
// Read AFTER the wait: this is what the caller gets, so it must be current. A wait
// that ended because the session was deleted reads an empty inbox.
const read = agentInbox.read(id, { receiver });
// that ended because the session was deleted reads an empty inbox. A client that
// hung up never sees this answer, so its read must not mark anything seen.
const read = agentInbox.read(id, { receiver: receiver && !wait?.aborted });
return { success: true, data: { sessionId: id, receiver, ...read, ...(wait ? { wait } : {}) } };
});
+4
View File
@@ -119,6 +119,7 @@ import {
abortOnClientHangUp,
} from '../route-helpers.js';
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
import { agentInbox } from '../agent-inbox.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
@@ -1680,6 +1681,9 @@ export function registerSessionRoutes(
// below re-looks-up the record by id, so the returned SessionState is unused here.
findPersistedSessionOrFail(ctx.store, id, req);
ctx.store.demoteOrRemoveSession(id);
// A detached session kept its agent inbox (server.ts _doCleanupSession drops it
// only on killMux); retiring the row is the delete, so the mail goes now.
agentInbox.drop(id);
// Mirrors the broadcast at the tail of the live-session cleanup path
// (_doCleanupSession in server.ts) — without it, other open tabs keep
// showing the retired row until their next unrelated fetch.
+7 -2
View File
@@ -1847,12 +1847,17 @@ export const SessionWaitOutputQuerySchema = z.object({
/**
* Body for `POST /api/sessions/:id/inbox`. The byte cap (`MAX_MESSAGE_BYTES`) is the
* store's, checked there in UTF-8 bytes; this bound is only the outer fence so a
* multi-megabyte body never reaches it. Multi-line text is fine: it is stored and
* multi-megabyte body never reaches it. It is 4× the byte cap in characters (a UTF-8
* character is at most 4 bytes), so every over-size message gets the store's answer,
* which tells the sender what to do instead. Multi-line text is fine: it is stored and
* handed back as data, never typed.
*/
export const InboxPostSchema = z
.object({
text: z.string().min(1, 'text must not be empty').max(16_384),
text: z
.string()
.min(1, 'text must not be empty')
.max(4 * 16_384),
})
.strict();
+3 -2
View File
@@ -1691,8 +1691,9 @@ export class WebServer extends EventEmitter {
// detached a few lines above, before `session.stop()`, so on a delete the
// session's own exit event never reaches the registry.
// The agent inbox goes with a DELETED session only. killMux=false is shutdown or
// detach, and a detached session comes back to the mail posted while it was away.
// Before cancelAll, so a reader released by it reads an empty inbox.
// detach: mail posted before that is still there when the session is re-attached
// (while detached, the routes answer 404, so nothing new arrives). Before
// cancelAll, so a reader released by it reads an empty inbox.
if (killMux) agentInbox.drop(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);