Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export const TELEMETRY_COMMAND_USAGE = [
export const CARD_COMMAND_USAGE = [
"Usage:",
" plannotator card create <title> [--column <column>] [--say <text>] [--you <text>] [--thread <id>] [--pr <ref>] [--ref <issue or PR>] [--key <key>]",
" plannotator card create --thread <id> [...] (the thread's subject is the title)",
" plannotator card create --thread <id> [...] (the thread's subject is the title; pass a short <title> too, as subjects run long)",
" plannotator card move <crd_id> <column> [--say <text>] [--you <text>] [--thread <id>] [--pr <ref>] [--ref <issue or PR>] [--reason <text>]",
" plannotator card say <crd_id> [<text>] [--you <text>] [--title <text>]",
" plannotator card link <crd_id> [--thread <id>] [--pr <ref>] [--ref <issue or PR>] [--say <text>]",
Expand All @@ -247,6 +247,8 @@ export const CARD_COMMAND_USAGE = [
`Columns: ${INBOX_CARD_COLUMNS.join(", ")} (icebox is the person's only).`,
"",
"Keeps a card on the person's Board in the Plannotator Inbox for work that outlasts a reply.",
"A title is 2–5 words naming the work (\"Pi update fix\"), never a sentence; status goes in --say.",
"When a card waits on the person, link the Inbox thread where they make the call (--thread), so \"You: …\" opens it.",
"Works only while the person has the Board on (Inbox Settings → Capabilities); off, it exits 1 with board_off.",
"Run 'plannotator inbox --help' for the full guide (its card section, shown while the Board is on).",
].join("\n");
Expand Down
4 changes: 2 additions & 2 deletions apps/hook/server/inbox-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ export const TOOL_GUIDE: Record<InboxMcpToolName, InboxToolGuide> = {
"Keep a card on the person's Board for work that outlasts the reply it was asked in: a lane or background task started, a PR opened, \"I'll send you X\". Not for a quick answer. Make it when you take such an ask, then keep it current with one short call at the moments you already note: work started, a PR opened, something sent to the person, waiting on them, shipped. The person reads the Board instead of asking each terminal where things are; each card shows how long ago anyone last wrote to it. A card is its own record: it never changes a thread or the person's list. The Board is something the person turns on (Settings → Capabilities in the Inbox); while it is off the Inbox does not list `card`, and a call answers `board_off` and writes nothing. Turned on, agents get `card` in their next session.",
"- `op`: `create`, `move`, `say`, `link`, `ship`, `drop` or `list`. Every op but create and list takes `card_id` (`crd_…`, from create or list); an op refuses a field it does not take.",
"- `column`: `backlog` (not started: work you were asked to do and have not begun goes here, beside the person's own ideas; a backlog card is never a signal to start, the person tells you when), `designing` (research, a plan, mocks, a sheet, options: anything that is not code), `your_look` (a non-code thing waits on the person: a sheet, options, questions, a triage call), `building` (code, or a PR not ready for them), `your_review` (code waits on the person: screenshots before a merge, a green PR to merge, a release go), `shipped` (merged or live, staging included), `dropped`. The stage is the kind of thing the next move makes: code, a PR or a release is building, anything else designing. Waiting on the person wins over work you keep doing beside it. Work you hold back yourself stays in its column, with the reason in `say`.",
"- `you`: what the person owes, one line, shown as \"You: …\" in your_look and your_review. Moving a card into either needs it; it is dropped when the card leaves them, and refused elsewhere. `say`: your last word, one line (\"lane running, sheet in ~1 h\"), shown in the other columns.",
"- create: `title`, or `thread_id` (a thread or message id of this project) to link it and take its subject as the title; `column` (default backlog; a live column or shipped), `say`, `you`, `pr`, `idempotency_key` (the same key answers the first card). The card is your session's: list shows it as yours.",
"- `you`: what the person owes, one line, shown as \"You: …\" in your_look and your_review. Moving a card into either needs it; it is dropped when the card leaves them, and refused elsewhere. Link the Inbox thread where the person makes the call (`thread_id`), so \"You: …\" opens it. `say`: your last word, one line (\"lane running, sheet in ~1 h\"), shown in the other columns.",
"- create: `title`: 2–5 words naming the work (\"Pi update fix\"), never a sentence; status goes in `say`. Or `thread_id` (a thread or message id of this project) to link it and take its subject as the title (subjects run long: pass a short `title` with it); `column` (default backlog; a live column or shipped), `say`, `you`, `pr`, `idempotency_key` (the same key answers the first card). The card is your session's: list shows it as yours.",
"- move: `column`, with any of `say`, `you`, `pr` and `thread_id`; to dropped it needs `reason`. say: `say` and/or `you`, or `title` to rename. link: `thread_id`, `pr` (`owner/repo#123`, or a GitHub, GitLab or Bitbucket PR URL) and/or `ref` (an issue or PR: `owner/repo#123`, or its GitHub or GitLab URL; move, create and ship take it too). The person sees each link's live state when they open the card; it never moves the card. Every write counts as hearing from the card.",
"- ship: when it is merged or live. A merged PR never moves a card by itself: ship it. Reported broken again: move it back to backlog. drop: `reason` (required from you, one line); the Board shows the person your drop and its reason until they look, and they can restore it.",
"- list: your cards in this project (`asked_by: \"anyone\"` for every card here) in the live columns and the icebox; `include_resolved` adds shipped and dropped. One line each: id, title, column, `You:` or said, when it was last heard, dropped reason (and whether the person has seen your drop yet), threads, PRs, the agent it belongs to. Read it after a compaction or in a new session to pick your work back up.",
Expand Down
2 changes: 1 addition & 1 deletion apps/skills/core/plannotator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ The Plannotator Inbox is one local window per machine where you leave the person
plannotator card <create|move|say|link|ship|drop|list> ...
```

Only while the person has turned the Inbox's Board on (Settings → Capabilities); it is off by default, and then every call answers `board_off` and writes nothing, so use it only when your inbox tool lists `card` or the person asks. The Board holds one card per piece of work that outlasts a reply, in the column of its stage (backlog, designing, your_look, building, your_review, shipped). Use the `card` action of a `plannotator_inbox` tool when you have one; this CLI is the same call from a shell. `plannotator card --help` gives the arguments, and `plannotator inbox --help` its card section while the Board is on. The icebox is the person's only.
Only while the person has turned the Inbox's Board on (Settings → Capabilities); it is off by default, and then every call answers `board_off` and writes nothing, so use it only when your inbox tool lists `card` or the person asks. The Board holds one card per piece of work that outlasts a reply, in the column of its stage (backlog, designing, your_look, building, your_review, shipped). Use the `card` action of a `plannotator_inbox` tool when you have one; this CLI is the same call from a shell. `plannotator card --help` gives the arguments, and `plannotator inbox --help` its card section while the Board is on. The icebox is the person's only. A title is 2–5 words naming the work ("Pi update fix"), never a sentence; where it stands goes in `say`. When a card waits on the person, link the Inbox thread where they make the call (`thread_id`), so "You: …" opens it.

## Other subcommands

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-guides/inbox-compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
- Send to another session (`POST /api/inbox/messages/:id/to`): re-addresses a person message that no session took yet (a thread the person started, a New message, a redirected reply, or a reply in a thread the person started) to a live session of its thread's project (`replyTarget`). A new snapshot of the same message (the last line wins); `409 already_delivered` once delivered, `409 thread_resolved` on an archived thread, `422 not_addressed` for anything else. A reply in a thread the person started, moved to a session that has seen nothing of that thread (it never took the root, the root is not addressed to it, and it never wrote there), wakes it with `continues_thread: true`, and `inboxWakeText` adds `inboxContinuesLine` (read the thread first with `read_thread`, then answer with `reply_to`). The window offers it after the asker grace (`GET /api/inbox/sessions?session=<id>` or the thread route's `?asker=`).
- A reply in a thread the person started, written before the agent answered, is stamped with the `to` of the message it answers (the root's current `to`) when it is written (`sendReply`), so like any addressed message it is handed only to that session and settled only by its delivery: another agent's `read_thread` or `wait_for_reply` never settles it.
- The thread's name (its chip, `thread_name`): the person may give one in compose (`thread_name` on `POST /api/inbox/messages`, checked by `checkInboxThreadName`: one line, 1 to 120 visible characters, no control or bidi characters; `422 validation_error` otherwise; refused with `409 thread_name_taken`, nothing written, while another OPEN thread of the project has the name (compared by `inboxThreadNameKey`; a resolved one does not hold it); empty means none; part of the Send's idempotency). Compose sends its own suggestion the same way with `thread_name_auto: true` (the chip the person left following the message): best effort, so one that is not a valid name or that another open thread has is dropped without refusing the Send, and it is stored with `named_by: { kind: "auto", at }`, which still lets the agent name the thread (`threadNamers` ignores it) and is replaced, once, by the agent's name; a retry compares only a name the person typed. Otherwise the agent names it ("Naming a thread the person started" under "Agent connections and the knob"). The root carries `named_by` (`{ kind: "person" | "auto", at }` or `{ kind: "agent", host, session, message_id, at }`); a name the person typed or an agent gave never changes. Routing (review of #1956): a compose name, typed or suggested, routes NOTHING. Another session's `send_message` with that `thread` starts its own thread; only the addressed session's `reply_to` replies land in the person's thread, and only an AGENT's name (given once, never changed after) indexes it in `namedThreads`, from which point named sends join it as for any agent-named thread. Compose names live in `personNamed`, read only by `threadNameTaken` (with the root's current name, since an agent may replace a suggestion), so two open threads never share a name. Proved by an independent model, `packages/shared/inbox/store-routing-model.test.ts` (built from the operations alone, with readdress, delete and reopen; it asserts a named send lands in a person's thread only when an agent named it so), beside the line-walk oracle in `store.test.ts`. A retried agent reply compares its `thread` only when the stored reply kept `thread_naming` (anywhere else `thread` with `reply_to` is ignored, and older stores never recorded `reply_thread`). The bold subject stays the short name above. A thread an AGENT named is indexed under its name (`namedThreads`), so later sends with that `thread` join it; a compose name never is (see "Routing" above).
- Drafts: one file per draft, `inbox/drafts/<drf_id>.json` (0600, folder 0700, temp then rename), `{ v: 1, id, created_at, updated_at, body, to: { host, session, project_root, project_name, started_at } | null, project, files: [upload], thread_name? }` (`thread_name` as the person typed it in the chip, at most 500 characters; absent while the chip follows the message; the Send checks it). Never in the event log, so never seen by agents, the MCP, phones, the relay or search. A save is a compare-and-set on `updated_at` (`base`: the `updated_at` the writer read, null to create; a mismatch is `409 draft_conflict` with the newer draft); `updated_at` always moves forward. `GET /api/inbox/drafts` adds each draft's chip, `status: live | ended | none` (its session live now, gone, or none chosen). Sending with `draft_id` deletes the draft. A draft never sends itself and is never re-addressed on its own. Storage in Settings counts the drafts' bytes; `uninstall --purge` removes them with `inbox/`.
- Drafts: one file per draft, `inbox/drafts/<drf_id>.json` (0600, folder 0700, temp then rename), `{ v: 1, id, created_at, updated_at, body, to: { host, session, project_root, project_name, started_at } | null, project, files: [upload], thread_name?, card? }` (`thread_name` as the person typed it in the chip, at most 500 characters; absent while the chip follows the message; the Send checks it. `card`: `{ id: crd_…, title }`, the Board card a Create thread compose was for, so sending the draft later still links the thread to it; anything else is `422 validation_error` on `card`). Never in the event log, so never seen by agents, the MCP, phones, the relay or search. A save is a compare-and-set on `updated_at` (`base`: the `updated_at` the writer read, null to create; a mismatch is `409 draft_conflict` with the newer draft); `updated_at` always moves forward. `GET /api/inbox/drafts` adds each draft's chip, `status: live | ended | none` (its session live now, gone, or none chosen). Sending with `draft_id` deletes the draft. A draft never sends itself and is never re-addressed on its own. Storage in Settings counts the drafts' bytes; `uninstall --purge` removes them with `inbox/`.
- Uploads (`POST /api/inbox/uploads?name=<name>&serverSession=<s>`, the raw body): streamed to a temp file in `blobs/` (never whole in memory), refused past `INBOX_UPLOAD_MAX_BYTES` (100 MB, under the 128 MiB request bound; `413 upload_too_large`), hashed and renamed to `blobs/<sha256>`, the kind decided by `decideInboxAttachmentKind`, and recorded as `inbox/uploads/pending/<upl_id>.json`. The name must be a plain file name (no `/` or `\`, none of the thread name's refused characters: C0 and C1 controls, U+2028/U+2029, bidi controls; at most 255 bytes) outside the dotenv family (`422 upload_refused`, "Not attached: .env files often hold secrets."). At send each upload becomes an ordinary `InboxAttachment` (`inboxUploadAttachments`): the blob copied (a clone where the filesystem has them; never a hard link, so an agent writing to its file cannot change the stored blob), read-only outside Windows (where a read-only file could not be swept), at `inbox/uploads/<att_id>/<name>`, its realpath the record's `path`, `named_path` null, so the tiles, viewers, download route and phone door carry it unchanged, and it never reads as changed since sent. The store's blob sweep after a deletion keeps the pending uploads' blobs and removes the `uploads/<att_id>/` files of deleted messages; an hourly sweep (and one at start) forgets pending uploads older than a day that no draft names, and the blobs only they held (`sweepInboxUploads`).
- Guards: every route is a window route (same origin; `serverSession` in the body, or the upload's query); the owner's page over the tailnet may compose and upload; the phone's door carries the sessions, a new thread, Send to another session and uploads (contract 7.39 to 7.41, 7.48; uploads never through the relay), never drafts.
- The window (`packages/inbox/compose/`: `ComposeDialog.tsx`, `ToPicker.tsx`, `ComposeFiles.tsx`, `DraftsPage.tsx`, `SendToAnotherSession.tsx`, the pure rules in `model.ts`, the session read in `useComposeSessions.ts`):
Expand Down
Loading
Loading