item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.
command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).
armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.
the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.
so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.
docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.
bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
431 lines
35 KiB
Markdown
431 lines
35 KiB
Markdown
# Command HQ — design record
|
||
|
||
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
|
||
it, kept current as answers landed.
|
||
|
||
**Status:** data model, the full REST API including both creation paths (web and `POST
|
||
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
|
||
failures — none of them this feature's). **Needs `bun db:push`** before any route answers with real data;
|
||
without it every route throws a Postgres "relation does not exist" 500. A member also needs
|
||
`confineUserTree` re-run once if their account predates tonight — see "A member could not reach it" under
|
||
"Working directory". **Frontend not built yet on the web side.** The mobile side is further along —
|
||
`apps/commandhq` exists and typechecks against this router (see `COMMS/command-hq/mobile.md`) — and as of
|
||
this revision has what it asked for to create a commander, though it has not yet been exercised against a
|
||
running server on either side. Every claim in this file is checked against the code, not aspirational —
|
||
see "API reference" for what a client can call today.
|
||
|
||
## API reference — build against this
|
||
|
||
Base path `/command-hq`, `core` permission (every signed-in account, no grant to hold), every query scoped
|
||
to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly
|
||
for exact request/response shapes; the table below is the map, not the full contract.
|
||
|
||
| Method | Path | Body | Notes |
|
||
| ------ | ------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
|
||
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
|
||
| PATCH | `/categories/:id` | `{name}` | rename |
|
||
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
|
||
| GET | `/tags` | — | schema exists; no UI planned for v1 |
|
||
| POST | `/tags` | `{name}` | |
|
||
| DELETE | `/tags/:id` | — | |
|
||
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
|
||
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
|
||
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
|
||
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
|
||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses |
|
||
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
||
|
||
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
|
||
duplicate it — `GET /commanders/:sessionId` returns only `{sessionId, categoryId, tagIds, running,
|
||
createdAt}`. A client wanting the full picture calls both.
|
||
|
||
**`GET /commanders` is the grid endpoint** and returns the richest shape: every field `/chat/sessions`
|
||
already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`, `messageCount`, …) plus
|
||
`categoryId`, `tagIds`, and `running` — a boolean, derived live from the same `task:started`/
|
||
`task:notification` fold `/chat`'s own background-job tray uses, not a stored value. This is the field to
|
||
poll if a client wants to know "did my watcher die" without opening it.
|
||
|
||
**A row whose transcript is gone is silently absent from every response.** Never a tombstone, never a 404
|
||
with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for
|
||
why that is the design rather than an oversight.
|
||
|
||
**Two creation paths, one underlying mechanism.** The web composer sends an ordinary `/chat` websocket turn
|
||
in the commanders directory and never calls `POST /commanders/new` — the row is written server-side the
|
||
moment a real transcript id exists. A client that cannot open that websocket with the right parameters —
|
||
mobile — calls `/commanders/new` instead, which drives the identical spawn path (`startClaudeTurn` in
|
||
`websocket.ts`, extracted so there is exactly one) with no live socket, and waits for the same transcript
|
||
id before answering. `POST /commanders` (bare) is neither of these — it is the low-level upsert both paths
|
||
ultimately rely on, not something a client calls to create anything new. See "How a commander is created".
|
||
|
||
## What it is
|
||
|
||
A home for **commanders** — long-running background jobs (watchers) the owner wants to find again, organise,
|
||
and check on. Deliberately separate from `/chat`: **no cross-pollination**. A commander is started from
|
||
inside Command HQ, not from an existing `/chat` conversation, and a `/chat` conversation never becomes a
|
||
commander after the fact.
|
||
|
||
Worked example the owner gave: a watcher that polls Gmail via IMAP every 10–60 minutes, compiles what came
|
||
in, and pushes a summary — so the owner can uninstall the Gmail app and stop getting a notification per
|
||
email. Command HQ is where that watcher lives, is named, is categorised, and is checked on.
|
||
|
||
## Route, surface, placement
|
||
|
||
- **`/command-hq`** — new dock item, new route, **core** (`kind: 'core'` in `permissions/registry.ts`, not
|
||
`app`). Every account gets it; there is no grant to hold. Core still needs the registry entry —
|
||
`assertPermissionTotality` refuses to boot on an unregistered router regardless of kind.
|
||
- **Per-user, not shared.** Every table carries `user_id`; every query is scoped to the caller. Nothing
|
||
about this is visible across accounts. The owner named a _possible_ future (a super-admin view of who is
|
||
running what) — do not build toward it, it costs nothing later because `user_id` is already there.
|
||
- Workspace/Panel framework, per the standing rule for every new dashboard route: `<WorkspaceView
|
||
workspace={ws} locked />`, panels under `officerdev/src/apps/CommandHQ/`, a rule added to `usePageTitle.ts`.
|
||
- **API-first.** `/command-hq` will also be a mobile app surface eventually — not this task's concern, but
|
||
every capability must be a real HTTP endpoint, not something only the web panel can reach.
|
||
|
||
## Layout, v1
|
||
|
||
Two columns.
|
||
|
||
- **Left: category list.** Simple for v1 — a flat list of the user's categories, click to select. Filtering
|
||
and search on this list are explicitly deferred (see "Later").
|
||
- **Right: commander grid**, scoped to the selected category. One card per commander.
|
||
- **Click a card → detail pane.** The right side splits to show it. What the detail pane contains beyond
|
||
"the conversation" is deferred — see "Later". For v1 it can be as simple as the chat transcript.
|
||
- **New commander** starts from inside Command HQ's own composer — not a repurposed `/chat` panel reached
|
||
from elsewhere. Almost certainly `EmbeddableChat` (`officerdev/src/apps/Chat/EmbeddableChat/`), which
|
||
already exists as a chat surface embeddable outside the `/chat` screen.
|
||
|
||
## How a commander is created
|
||
|
||
Resolved and **built, both paths** — there is no separate "file this into Command HQ" step on either one.
|
||
Starting a new commander **is** the creation flow.
|
||
|
||
**Web:**
|
||
|
||
1. The composer sends an ordinary `/chat` websocket turn with `context: 'command-hq'`.
|
||
2. `resolveChatCwd` (`websocket.ts`) reads that context and resolves `getCommandersDir(email)` itself —
|
||
`msg.cwd` is never consulted on this branch. The client does not choose, or even need to know, where
|
||
commanders live.
|
||
3. The moment the session has a real transcript id, `createMessageHandler` writes the `command_hq_sessions`
|
||
row — the same `session:claude`-then-`result` seed `chat_session_models`' `record()` already waits on,
|
||
with a sibling closure (`fileAsCommander`) hung off the same two triggers, gated on a `commandHq` flag
|
||
threaded in from `msg.context === 'command-hq'` at the call site.
|
||
|
||
**Mobile needed a different door.** `COMMS/command-hq/mobile.md` — read by the platform side 2026-08-18,
|
||
after the mobile client had already been built against the router — found the web mechanism does not
|
||
transfer: mobile sends chat turns with its own parameter set, so a context flag would have to be carried
|
||
correctly by two independent clients, and a client that gets it wrong silently creates an ordinary `/chat`
|
||
conversation in the wrong directory rather than failing loudly. Decided with the owner, and now built:
|
||
|
||
```
|
||
POST /command-hq/commanders/new
|
||
body: { prompt: string, displayText?: string, categoryId?: number | null }
|
||
-> { sessionId, cwd }
|
||
```
|
||
|
||
`prompt` is required — confirmed with the owner 2026-08-18. The actual usage pattern it needs to support:
|
||
a commander often starts as an ordinary back-and-forth (figuring out the terms of the job with the agent)
|
||
long before a background job is ever armed via the existing dialog — sometimes the first message really is
|
||
just "we've already discussed this, do it." Either way there is always a real first message, the same as
|
||
any conversation; there is no "empty commander" case to design around.
|
||
|
||
**`displayText` was missing from the first version of this route, and the mobile side caught it** before
|
||
it was exercised against a real server. A commander created to become a background job sends the FULL
|
||
`buildBackgroundJobPrompt` mechanics as `prompt` — a page of preamble, the owner's actual sentence only at
|
||
the end — and `command_hq_sessions` has no name column, so the title comes from `session.meta.title =
|
||
(displayText ?? prompt).slice(0, 100)`, exactly the precedence the websocket path already uses (both
|
||
clients already send `displayText` there — `sendWithHiddenPrefix` on web, `useSessionView.ts` on mobile).
|
||
Without it, every commander created through this endpoint would have titled itself with the mechanics
|
||
text, and the grid would have been a column of identical, unreadable cards. Fixed same session, before
|
||
anyone had run it for real.
|
||
|
||
**How it actually gets the id.** Claude assigns a transcript id only once a turn has run far enough to
|
||
report `system.init` — there is no way to reserve one in advance. So the endpoint genuinely starts the
|
||
turn: `startClaudeTurn` (`websocket.ts`), the turn-starting half of `handleClaudeCodeChat` extracted so both
|
||
the websocket path and this endpoint drive ONE spawn function rather than two that drift, called with
|
||
`ws: null`. It resolves as soon as the harness's `session:claude` arrives — not once the whole turn finishes
|
||
— via an `onOutcome` callback that mirrors what a live socket would already have been told (a refusal
|
||
reason, a spawn failure, or the transcript id), so an HTTP caller gets a real answer instead of a generic
|
||
timeout. The turn then keeps streaming exactly like any other session; the endpoint's job ends the moment
|
||
there is something real for the client to attach to.
|
||
|
||
**The category race, and why it resolves deterministically anyway.** `fileAsCommander` (the web path's
|
||
same hook, which this endpoint's turn ALSO passes through since `context: 'command-hq'` is set either way)
|
||
always files with no category — it has no way to know one was requested. So the endpoint does its own
|
||
`createCommandHqSession` (idempotent, upsert-on-conflict-do-nothing — safe whichever of the two insert
|
||
attempts actually lands first) followed by an unconditional `setCommandHqSessionCategory` UPDATE. The
|
||
UPDATE always runs last and always wins, so the category the client asked for is what's left standing
|
||
regardless of ordering.
|
||
|
||
No name is stored at creation, on either path — see "Naming", next.
|
||
|
||
## Naming — resolved, and it changes the schema
|
||
|
||
**`/chat` never persists a title to Postgres.** `renameClaudeSession` (`claude-sessions.ts:646`) appends a
|
||
`summary` entry straight into the transcript's own JSONL — Claude's own on-disk format is the title's only
|
||
home. An unrenamed session's title is the first prompt, truncated, computed on the fly. There is no title
|
||
column anywhere in Postgres for a `/chat` session, ever.
|
||
|
||
Confirmed with the owner: **`command_hq_sessions` carries no `name` column.** The grid reads a commander's
|
||
title live, the exact same way `/chat`'s list already does. Renaming a commander **is** `renameClaudeSession`
|
||
— the same function, same route, same file on disk — not a second mechanism that could disagree with the
|
||
first. One title, one place it can live, zero drift possible by construction.
|
||
|
||
## Data model
|
||
|
||
Tables prefixed `command_hq_` per the house rule (`purge` finds a feature's tables by prefix; this one keeps
|
||
the platform's naming convention even though it lives in core, a deliberate choice made explicit rather than
|
||
an accident of copying a plugin schema).
|
||
|
||
### `command_hq_sessions`
|
||
|
||
One row per commander.
|
||
|
||
| column | notes |
|
||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `user_id` | owner of the row, always the same account that owns the session |
|
||
| `session_id` | **Claude's transcript uuid**, not Officer's `sessionId` — the same id `chat_session_models` keys on, for the same reason: everything else that joins to a session (models, events) uses this id |
|
||
| `category_id` | FK to `command_hq_categories`, nullable — one category per commander (see below) |
|
||
| `created_at` | |
|
||
|
||
No `name` column (see "Naming"). No `status` column, deliberately: liveness is **derived**, the same way
|
||
the background-job tray already derives it — fold `chat_session_events` for the session, `task:started`
|
||
minus `task:notification` (`collectRunningTasks` in `websocket.ts` is the existing implementation of exactly
|
||
this fold; reuse it, do not reinvent it). A stored status would be a second source of truth that goes stale
|
||
precisely in the case this feature is used for (see "Later" — silent death on sidecar restart) and nothing
|
||
would ever correct it.
|
||
|
||
### `command_hq_categories`
|
||
|
||
User-created, not a fixed enum. One category per commander — a plain FK column on `command_hq_sessions`, not
|
||
a join table.
|
||
|
||
| column | notes |
|
||
| ------------ | ----- |
|
||
| `user_id` | |
|
||
| `name` | |
|
||
| `created_at` | |
|
||
|
||
### Tags — infrastructure now, UI not specified
|
||
|
||
Resolved: build the tag infrastructure alongside categories, not deferred. Many-to-many, unlike categories:
|
||
|
||
- `command_hq_tags` — `user_id`, `name`, `created_at`.
|
||
- `command_hq_session_tags` — join table, `(session_row_id, tag_id)`.
|
||
|
||
Nothing in the spec describes a tag picker or tag filter chips for v1's UI (only the category list was
|
||
described). Build the schema; treat the UI as open until asked for.
|
||
|
||
## Working directory — every commander shares one directory per user
|
||
|
||
Resolved, and it came with an explicit reversal worth keeping: the owner's first instinct was a directory
|
||
**per commander**, each with its own isolated cwd. Retracted on the spot — creating a fresh directory at the
|
||
moment a commander starts races the harness's own spawn (the cwd has to exist before or exactly when the
|
||
first turn fires, and centralising that creation moment is exactly the kind of timing hazard Claude Code's
|
||
own directory-per-project convention was not designed to be driven by). Simpler and safer: no per-commander
|
||
directory at all.
|
||
|
||
**`DATA_PATH/<email>/commanders/`** — one fixed directory, shared by every commander a user has. Every
|
||
commander's `cwd` is this same path. This is precedented, not new: `getEmailAccountsDir` in `data-path.ts`
|
||
already follows the identical shape (`join(DATA_PATH, ownerEmail, 'email_accounts')`) for a different
|
||
feature's per-user directory. A `getCommandersDir(email)` alongside it is the natural addition. Needs to
|
||
exist once, idempotently (`mkdirSync(..., { recursive: true })`, the pattern already used in
|
||
`websocket.ts`), created lazily before the first commander's first turn — never per-commander, which is the
|
||
exact race that was rejected.
|
||
|
||
**This is a deliberate departure from ordinary `/chat` cwd resolution for the owner.** A normal owner chat
|
||
runs in `getOwnerHomeDir` — the owner's real login home — while `getHomeDir` (`DATA_PATH/<email>/home`) is
|
||
otherwise reserved for non-owner accounts and the pipeline executor. Commanders use the managed,
|
||
`DATA_PATH`-rooted directory **even for the owner**, which is exactly what keeps them out of the owner's own
|
||
project transcripts and out of `/chat`'s history for the same underlying reason categories exist: a
|
||
commander is not a project conversation, and should not be filed as if it were.
|
||
|
||
### A member could not reach it, and this is now fixed — but only going forward
|
||
|
||
Found while wiring the creation flow, not guessed at: `getCommandersDir` is a sibling of `home`, and
|
||
`confineUserTree` (`os-user.ts`) confines **every** sibling of `home` to 700, owned by the SERVICE user,
|
||
"unreachable even by name" — correct for platform-written directories like `attachments/`, and exactly
|
||
wrong here, because a member's commander turn runs as the MEMBER's own Linux account via `setpriv`. This is
|
||
the identical shape of bug `resolveChatCwd`'s own comment already tells a story about (`general_chat_sessions`,
|
||
abandoned for the same reason) — a working directory the turn cannot `cd` into, surfacing as an agent that
|
||
fails on its own first command.
|
||
|
||
**Fixed in `confineUserTree` itself**, not by abandoning the directory: it now chowns `commanders/` to the
|
||
member exactly as it already chowns `home`, and excludes it from the loop that confines every other
|
||
sibling. No ACL, unlike `home` — the file browser needs one because it reads a member's home from inside
|
||
the platform process as the service user; Command HQ has no equivalent, since it reads a member's commander
|
||
transcripts through `listTranscriptsAs`, the same as-the-member read `/chat` already uses for anyone's
|
||
transcripts. Plain ownership is the whole requirement, same as `~/.claude/projects` itself.
|
||
|
||
**The gap this leaves:** `confineUserTree` runs at provisioning time. An account provisioned _before_ this
|
||
fix landed will not have `commanders/` chowned until it is provisioned again — the existing admin
|
||
reprovision action (`provision-linux-route.ts` / `manage-users.ts`) already re-runs `confineUserTree`
|
||
idempotently, so no new machinery was needed, but a member who existed before tonight needs that one
|
||
manual re-run before their first commander turn will actually spawn. Worth a release note, not a code
|
||
change.
|
||
|
||
## The `/chat` ↔ `/command-hq` relationship
|
||
|
||
**One predicate, opposite sign, both directions read from the same join.**
|
||
|
||
- `/chat`'s session lists (`listClaudeSessions`, `listAllClaudeSessions`, and the ChatHistory app) **exclude**
|
||
any session with a `command_hq_sessions` row.
|
||
- `/command-hq`'s card grid **includes only** sessions with a row, joined against the live session list.
|
||
|
||
**The source of truth is always the live session list** (transcripts on disk, exactly what `/chat` reads
|
||
today), never the table. `command_hq_sessions` is a filter and a set of annotations on top of that list, not
|
||
an independent record of what sessions exist. Consequences the owner confirmed explicitly:
|
||
|
||
- A row whose transcript is gone (deleted, home reset, whatever) is simply **absent from both lists** — it
|
||
is not rendered as a tombstone, and nothing needs to notice. It is "someone else's problem" by design; the
|
||
owner is not developing for people who will be surprised by this.
|
||
- A **purge button** can sweep `command_hq_sessions` for rows whose `session_id` no longer resolves to a
|
||
live session and delete them. Housekeeping only — correctness never depended on it running.
|
||
- A session **never moves the other direction**. There is no unfile path, and none is planned — a commander
|
||
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
|
||
never actually overlap.
|
||
|
||
## Built on, mostly unchanged
|
||
|
||
Everything about _how a background job runs_ was out of scope for the original build — a place to browse,
|
||
name and categorise the sessions the existing system produces. That held until real testing found the
|
||
mechanism itself wanting; see "The watcher loop" below for what changed and why. Otherwise, unmodified:
|
||
|
||
- The whole lifecycle: `run_in_background`/`Monitor`, `task:started`/`task:notification`,
|
||
`chat_session_events` as the durable log, `collectRunningTasks` as the fold, the tray.
|
||
- Stop/pause/restart routes under `/chat/tasks/:id`.
|
||
- `renameClaudeSession` — Command HQ's rename action IS this, not a new one.
|
||
- `officer-notify` — assume it works and is wired up separately; do not build a parallel notification path
|
||
for this feature.
|
||
|
||
## The watcher loop — exit, act, drain, re-arm
|
||
|
||
`COMMS/command-hq/watchers-what-broke.md` and `watcher-loop-proposal.md`, both 2026-08-18: the mobile side
|
||
actually ran a watcher (poll `git ls-remote`, react to each push) through the dialog, and the mechanism
|
||
broke in ways no amount of reading the code would have found — two watchers alive at once because a new
|
||
one was armed before the old was reaped, a watcher firing on the agent's own push to the branch it was
|
||
watching, "keep going" silently meaning the agent would never see a firing again because a background
|
||
process reaches the model only when it EXITS. The owner's call, confirmed 2026-08-18: **implement the
|
||
proposal as written, before the first real tests** — this governs the agent's behaviour generally, not
|
||
only Command HQ, so it is not optional groundwork the way most of this document is.
|
||
|
||
**Built:**
|
||
|
||
- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
|
||
boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the proposal's one-sentence
|
||
contract (`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will
|
||
be handed back, with the change. Do the work. The harness re-arms you — do not re-arm yourself, and do
|
||
not build your own polling loop on top of this one."_ The closing "IF IT EXITS" section is rewritten for
|
||
`handoff` too, so it does not contradict rule 7 by telling the model an exit means stay-stopped.
|
||
- **`isCommander`, gating the promise to where it can be kept.** The proposal frames this entirely in terms
|
||
of commanders — "a commander already has a row," arming state's only home — and outside one there is
|
||
nowhere to persist "this should keep re-arming itself," so nothing would fulfil the promise. Rather than
|
||
make it anyway, `BackgroundJobDialog` only offers `handoff` when `isCommander` is true, defaulting to it
|
||
there and to the pre-existing `stop` everywhere else. **Not yet wired to `true` anywhere** — Command HQ's
|
||
own composer, which would set it, does not exist yet (web) or does not use this dialog (mobile has its
|
||
own). Every existing `/chat` caller is unaffected: two options, `stop` default, exactly as before tonight.
|
||
- **Stopped no longer reads as failed.** `watchers-what-broke.md` §5: a job Officer had just SIGTERM'd came
|
||
back `status: 'failed'` (exit code 144) from the harness's own classification, which this codebase does
|
||
not control. Fixed client-side, at the one place it can be fixed cheaply: `stopTask` in
|
||
`BackgroundTaskTray.tsx` now also calls `markStopRequested(taskId)`
|
||
(`background-job-origin.ts`), and `useChat.ts`'s `task:notification` handler overrides the displayed
|
||
status to `stopped` when a stop was just requested for that id, regardless of what the harness reported.
|
||
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
|
||
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
|
||
|
||
**Built, item 4 (ahead of item 3 — plumbing needed to exist before the trigger could be tested):**
|
||
|
||
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherLastFiredAt`.** Unlike `running`,
|
||
`watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming this"), not a live
|
||
process fact the platform is reporting on, so nothing external can silently falsify it the way a sidecar
|
||
restart falsifies `running`. `watcherTaskId` is closer to that rejected shape and accepts the same known
|
||
gap `running` already lives with — see the schema file's docstring.
|
||
`armCommandHqWatcher` / `recordCommandHqWatcherFired` / `disarmCommandHqWatcher` in `queries.ts`; exposed
|
||
read-only on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH /commanders/:sessionId` accepts
|
||
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
||
running turn has).
|
||
- **`armWatcher` threaded end-to-end, client to server, and CONSUMED NOWHERE YET.** `BackgroundJobDialog`
|
||
computes it (`watching && onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` →
|
||
`sendPrompt`'s new last parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant →
|
||
`startClaudeTurn`'s `msg.armWatcher`. Every step type-checks and the full suite is unaffected, because
|
||
nothing reads the field on the server yet — safely inert plumbing, not a shipped mechanism.
|
||
|
||
**Not yet built — item 3, and a real design question found while trying to build it:**
|
||
|
||
The original plan here was wrong and is worth recording as wrong rather than quietly fixed: `task-restart.ts`'s
|
||
`restartBackgroundTask` looked like the obvious reuse — it already recovers a command from a transcript and
|
||
respawns it detached. **It is not reusable for this.** A restarted job is deliberately outside the SDK's
|
||
own `Monitor` tracking — `chat.ts`'s restart route emits its own `task:notification` via `emitToSession`
|
||
specifically because the harness stopped watching. That path updates the UI; it does not reach back into
|
||
the agent's own context, because there is no mechanism by which it could. A `handoff` watcher's whole
|
||
point is the OPPOSITE: the agent must be told again when it fires again, which only happens through the
|
||
SDK's own notification delivery, which only exists for a job the SDK itself is tracking.
|
||
|
||
So harness-owned re-arm cannot be a silent platform-side respawn. It has to drive the AGENT to reissue its
|
||
own `run_in_background`/`Monitor` call — the platform's job is to make that reissue AUTOMATIC and
|
||
CORRECT (the right command, the right moment, no double-arming) rather than something the model has to
|
||
remember across turns, not to do the reissuing itself. Concretely, likely shape: once a fired watcher's
|
||
"acting" turn ends (`result`, for a commander whose `watcherTaskId` just produced a `task:notification`),
|
||
the platform sends a SYNTHETIC follow-up turn — not from the user — instructing the agent to re-arm using
|
||
the exact command `commandForTask` recovers from the original transcript. `commandForTask` is still the
|
||
right reuse; `restartBackgroundTask` is not. Not implemented — raised with the owner before writing it, for
|
||
the second time tonight, because getting the mechanism itself wrong is more expensive to discover after
|
||
the fact than before.
|
||
|
||
Suggested order from the proposal: (1) prompt + dialog — done, (2) stopped-vs-died — done, (4) arming
|
||
state and plumbing — done ahead of schedule, (3) the actual re-arm trigger — open, blocked on confirming
|
||
the synthetic-turn design above. "Nothing here blocks the first live test of the apps; all of it blocks
|
||
trusting a watcher that is meant to run for months" — the proposal's own words, and the reason this is
|
||
still open rather than rushed.
|
||
|
||
## Later — explicitly deferred, not forgotten
|
||
|
||
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
|
||
scope for tonight."_
|
||
|
||
- **Silent death is invisible.** A background job is a child of the agent sidecar process, not of Officer.
|
||
`pm2 restart officer-claude-code` kills every running watcher with **no** `task:notification` ever
|
||
emitted, so `collectRunningTasks` (started-minus-notified) reports it running until the 7-day event prune.
|
||
For a watcher meant to run for months, this is the actual failure mode — a card that says "running" and
|
||
has been dead since the last deploy. Deferred anyway, because tonight is development against a live server
|
||
that gets restarted constantly; the target environment (a server nobody restarts) makes this rare in
|
||
practice. When it is picked up: `task-restart.ts`'s `pidsHoldingFile` (`fuser` against the task's output
|
||
file, run as the owning user) is the existing liveness probe used by the restart watcher — comparing "log
|
||
says running" against "pid check says running" is what turns a false "running" into a third, honest state
|
||
("vanished"). Cost matters here: `fuser` is a subprocess spawn per task, so this is a detail-pane check
|
||
or a batched sweep on load, never a per-card check on every render of the grid.
|
||
- **No durable job record.** A commander's only record of its own history is `chat_session_events`, which is
|
||
swept after 7 days. The owner does not care how many times a watcher fired or what each firing found — the
|
||
chat transcript being the record is intentional, not a gap to fill.
|
||
- **Detail pane contents beyond "the conversation".** The owner asked for logs, status, and transcript
|
||
together in a split view; scoped down to "click a card, see its details" for v1, with the specifics
|
||
(separate log tail vs transcript vs live status) left open. `/chat/sessions/:id` (transcript) and
|
||
`/chat/tasks/:id` (log tail or subagent transcript) already cover the raw material.
|
||
- **Filtering and search on the category list.** Named explicitly as v2 in the first message of the spec.
|
||
- **Tag UI.** Schema is in scope for v1 (see above); a picker, filter chips, anything user-facing is not.
|
||
- **Category management UI details** — delete behaviour (default: uncategorise the affected commanders
|
||
rather than cascade-delete their rows, not explicitly confirmed), reordering, anything beyond create/list.
|
||
- **Multi-account visibility for the owner** — "sometime for the next version we will want the super user
|
||
to have visibility of who is running what." `user_id` on every table already makes this a query change,
|
||
not a schema one.
|
||
|
||
## Resolved, previously open
|
||
|
||
- **Creation flow** — resolved, see "How a commander is created".
|
||
- **Naming / title storage** — resolved, see "Naming".
|
||
- **Category cardinality** — resolved: one per commander, a column not a join table. Tags cover the
|
||
many-per-commander case.
|
||
- **`/clear` chains** — the owner's answer, plainly: nobody sane runs `/clear` on a commander, and this is
|
||
not being built for the case where they do. A commander's `session_id` is one transcript; chain-splicing
|
||
is not replicated.
|
||
- **Working directory** — resolved, see "Working directory" above. Not something the owner had originally
|
||
planned for; raised and settled within this same conversation.
|
||
|
||
## Still open
|
||
|
||
Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is
|
||
created" — with `prompt` required, confirmed with the owner 2026-08-18. Neither path (web or mobile) has
|
||
been exercised against a running server yet; that is the next real test, not a design question.
|