owner's call, confirmed 2026-08-18: implement watcher-loop-proposal.md
exactly as written, before the first real tests — this governs agent
behavior generally, not just command hq. items 1-2 of the proposal's own
suggested order, done; items 3-4 (harness-owned re-arm, arming state on the
commander row) are the harder half, next.
FireBehavior replaces the stopOnFire boolean: 'stop' | 'handoff' |
'keep-going'. handoff carries the proposal's one-sentence contract verbatim
("the harness re-arms you — do not re-arm yourself, and do not build your
own polling loop on top of this one") and rewrites the closing IF IT EXITS
block so it stops contradicting rule 7 (the old universal "do not restart
it, i will start it again" directly lied about what handoff jobs actually
do).
isCommander gates the option to where the promise can be kept. the proposal
frames this entirely in terms of commanders — arming state's only home is
the commander row — so outside one there's nowhere to persist "keep
re-arming this," and offering the option there would make a promise nothing
fulfills. not wired to true anywhere yet: command hq's own composer, which
would set it, doesn't exist. every existing /chat caller is unaffected —
two options, stop default, unchanged from before tonight.
stopped-vs-died (§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 — the tray already
knows the instant stop is clicked, so it now also marks that, and
useChat's task:notification handler overrides the displayed status
regardless of what the harness reported. known gap, stated in both the code
and the doc: in-memory, so it doesn't survive a refresh in the narrow window
between clicking stop and the notification landing.
docs/command-hq.md gains "the watcher loop" section: what's built, what
isn't, the proposal's own suggested order followed as given.
bunx tsgo clean. suite: 1038 pass (+9 new tests), same 7 pre-existing
baseline failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
404 lines
32 KiB
Markdown
404 lines
32 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.
|
||
|
||
**Not yet built — the harder half:**
|
||
|
||
- **Harness-owned re-arm.** The proposal's actual infrastructure ask: once the agent's turn responding to a
|
||
firing ends, the PLATFORM (not the model) automatically respawns the original watch command as a fresh
|
||
background task. The natural implementation reuses `task-restart.ts`'s existing `commandForTask` (already
|
||
reconstructs a shell command from a transcript's tool_use/tool_result pairing) and its detached-spawn
|
||
machinery — re-arming becomes "run the restart path again, automatically, keyed off a `result` event for
|
||
a commander whose watcher just fired," rather than new command-recovery logic.
|
||
- **Arming state on the commander row.** `armed`, the current watch task's id, `lastFiredAt` — the
|
||
proposal's own suggested order puts this LAST, "once 3 exists to write it." Settles "one armed watcher
|
||
per commander" as a natural side effect (the row can refuse a second) and "stopped vs died" for commanders
|
||
specifically (the thing that re-arms already knows which it was).
|
||
|
||
Suggested order from the proposal, followed here: (1) prompt + dialog — done, (2) stopped-vs-died — done,
|
||
(3) harness-owned re-arm, (4) arming state. "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 (3)
|
||
and (4) are 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.
|