mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
002de32a08
commit
c6dfaa2cac
34 files changed
+2308
-83
No files matched your search
+84
-1
@@ -591,10 +591,93 @@ request returns. The capture runs synchronous tmux calls on the server; the
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
|
||||
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
|
||||
|
||||
## Agent inbox
|
||||
|
||||
A per-session mailbox: another agent (or the user) posts a message, and the
|
||||
receiving agent reads it when it chooses to. Nothing is typed into the
|
||||
receiver's pane, which is the difference from `POST …/input`: a post does not
|
||||
start or interrupt a turn, and the text may span several lines. Design and the
|
||||
reasons behind each rule: [discussion #558](https://github.com/Ark0N/Codeman/discussions/558).
|
||||
|
||||
| Method | Route | Body / query | Answer (`data`) |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` |
|
||||
| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=<ms>` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` |
|
||||
| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` |
|
||||
| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` |
|
||||
|
||||
A message is `{ id, from, text, createdAt, seen }`. `from` is a session id or
|
||||
`null` (the user); `seen` says whether the receiver had read it before this read.
|
||||
|
||||
**Who is calling.** The server resolves the caller from the
|
||||
`X-Codeman-Parent-Session` header, the one `codeman agent` sends on every
|
||||
request, with the same resolver as session lineage: a live session the caller
|
||||
can see, with the same owner as the receiving session, matched by exact id or a
|
||||
unique prefix of at least 8 characters. Anything else counts as no session.
|
||||
The body cannot name a sender (`InboxPostSchema` is strict). In multi-user mode
|
||||
that means a caller cannot claim to be another owner's session. In single-user
|
||||
mode every local process holds the same credentials, so the header is a hint the
|
||||
client gives about itself, not proof.
|
||||
|
||||
**Read until ack.** A read returns every pending message, oldest first, and
|
||||
removes nothing. Only `POST …/ack` removes, by id. Acking an id that is already
|
||||
gone is a success, so a retry after a lost response does not fail.
|
||||
|
||||
**Seen is the receiver's.** A read marks messages seen only when the resolved
|
||||
caller is the receiving session itself (`receiver: true`). Any other read, a
|
||||
coordinator checking a worker or a request without the header, is a peek and
|
||||
changes nothing. `ack` from anyone but the receiver is `403 FORBIDDEN`.
|
||||
|
||||
**`?wait=`** blocks while the inbox holds nothing **unseen**. A message that
|
||||
was read but not acked does not end the wait, so a receiver that keeps one
|
||||
pending does not spin. The value is clamped to the agent-wait bounds like
|
||||
`timeout` on `/wait`, a timeout is a `200` with `wait.timedOut: true`, and the
|
||||
waiter counts against the same per-session, per-owner and total caps (`409
|
||||
SESSION_BUSY` / `429 RATE_LIMITED`). `wait` has the shape of the other wait
|
||||
results plus `posted` (a post released it). A session deleted during the wait
|
||||
answers `wait.ended: true` with an empty inbox.
|
||||
|
||||
**Limits.** 200 messages, 16384 bytes (UTF-8) per message, 512 KiB of text per
|
||||
inbox. A message over the per-message cap is `400 INVALID_INPUT`. A post that
|
||||
would cross either inbox cap is `409 CONFLICT` and nothing is stored; the oldest
|
||||
message is never dropped to make room. Messages expire 12 hours after their
|
||||
`createdAt`, read or not.
|
||||
|
||||
**Summary.** `GET /api/v1/agent-inbox/summary` lists every session the caller
|
||||
can see that has mail or a parked receiver: `pending`, `unseen`,
|
||||
`oldestUnseenAt` (epoch ms, `null` when nothing is unseen) and `parked` (the
|
||||
receiver itself is waiting on `?wait=` right now). Counts only, never text. It
|
||||
is not part of the session object, so a post does not broadcast
|
||||
`session:updated`.
|
||||
|
||||
**Lifetime.** An inbox is deleted with its session (`DELETE
|
||||
/api/v1/sessions/:id` and every other delete path), not when a session is
|
||||
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
|
||||
deletes the file and keeps what is in memory. The file is never read by
|
||||
`/api/search`, and the file viewer refuses it.
|
||||
|
||||
```bash
|
||||
# coordinator: leave a task for a worker
|
||||
curl -s -X POST "$API/api/v1/sessions/$WORKER/inbox" \
|
||||
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg t "$(cat task.md)" '{text: $t}')"
|
||||
|
||||
# worker: wait for new mail, handle it, then ack by id
|
||||
curl -s "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox?wait=300000" \
|
||||
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" | jq '.data.messages'
|
||||
curl -s -X POST "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox/ack" \
|
||||
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \
|
||||
-d '{"ids":["<id>"]}'
|
||||
```
|
||||
|
||||
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
|
||||
|
||||
A `multipart/form-data` body with one `image` part. The file is written into the
|
||||
|
||||
@@ -688,6 +688,20 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
|
||||
|
||||
Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||
|
||||
### Agent inbox
|
||||
|
||||
**Agent inbox** (discussion #558): a per-session mailbox one agent posts to and another reads, so a coordinator can hand a worker text without typing into its pane. Store `web/agent-inbox.ts` (pure, `sessionWaits`-style singleton, no IO), routes `routes/inbox-routes.ts`, CLI verbs `codeman agent post|inbox|ack`, opt-in snapshot `web/agent-inbox-persistence.ts`. Nothing in it writes to a pane, and it is kept out of `toState()` so a post does not fan out a `session:updated` to every browser; the counts live behind `GET /api/agent-inbox/summary` (owner-scoped, counts only, never text), which `codeman agent ls` merges into its INBOX column.
|
||||
|
||||
⚠️ **Read until ack.** A read removes nothing; the same messages come back until the receiver acks them BY ID (`POST …/inbox/ack`, idempotent: an id already gone is not an error). Acking by id rather than "everything I read" is what keeps a message posted between the read and the ack from being lost.
|
||||
|
||||
⚠️ **Seen and ack belong to the receiver.** The caller is whatever `resolveParentSessionId()` makes of `X-Codeman-Parent-Session` (live session, same owner as the receiver), never a body field (`InboxPostSchema` is strict). Only a read whose caller IS the receiver marks messages seen; anyone else's read is a peek. An ack from anyone else is a 403, not a silent no-op, so a coordinator never believes it cleared a worker's mail. In multi-user mode this stops a caller from naming another owner's session; in single-user mode every local process holds the same credentials, so the header is the client's own claim about itself, which is all the seen/peek split needs.
|
||||
|
||||
⚠️ **`?wait=` parks while nothing is UNSEEN**, not while nothing is pending: a receiver holding a seen-but-unacked message would otherwise get an immediate answer on every loop and spin. Inbox waiters are the registry's third waiter kind (`waitForInbox`/`notifyInbox`), so they count against the same per-session/owner/total caps and are ended by `cancelAll` like signal and output waiters. `notifyInbox` runs AFTER the store, so a woken reader finds the message. `parked` in the summary counts receiver waiters only.
|
||||
|
||||
⚠️ **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. 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`.
|
||||
|
||||
### Voice dictation via Claude
|
||||
|
||||
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses.
|
||||
|
||||
Reference in new issue
Block a user