Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 55ecbf4fb8 the handoff radio was still rendered nowhere, one layer above where it was fixed
"Exit and hand back to the agent, then re-arm" is the only fire behaviour a commander wants, and it is the
dialog's own default inside one. It was missing from the dialog in Command HQ.

The previous fix wired the consumer: isCommander={manager.context === 'command-hq'}, with a comment about
how the prop had never been passed by anybody. The producer was still not feeding it. EmbeddableChat builds
its manager from its OWN props — useEmbeddableChat(params) — and `context` there is a different value from
the `context` handed to useChat. Command HQ set the hook's and not the component's, so manager.context was
undefined, and the radio stayed hidden in the one place it exists for.

Two hooks named alike sitting on either side of that boundary is what hid it twice: hooks/useChat.ts and
EmbeddableChat/useEmbeddableChat.ts, one taking a `context` option, the other a `context` prop, neither
reading the other's.

Found by the owner trying to arm a deploy commander and finding only "stop when it fires", a one-shot,
where a watcher that re-arms was the whole point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:42:04 +00:00
pastilhasandClaude Opus 5 926ce86b83 2026.08.21.02 — command hq's transcripts render, and live separates commanders
Frontend and one chat route. No schema change and no dependency change, verified against the previous
tag rather than assumed: nothing in this range touches src/schema.ts, package.json dependencies or
bun.lock. The plan says so explicitly, because a host that runs db:push here has run something the
release did not ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:33:15 +00:00
pastilhasandClaude Opus 5 e1ad680475 a commander opens in /chat, where you were already reading
Clicking one in the Live list navigated to /command-hq — out of the screen, into a different route with a
different layout, having lost your place in the list you were scanning.

I argued for that in the commit before this one: /chat renders the transcript without the category, cost,
watcher state or the disarm control. It loses to a simpler argument. A commander IS a chat — same harness,
same transcript, resumable by the same route — and being thrown out of the page you are reading costs more
than metadata you did not ask for. Command HQ is where you go to MANAGE a commander, which is a separate
errand from reading what it just said.

Verified rather than assumed, because a commander's cwd is not one of /chat's directory groups: the
default-group load misses, and GET /chat/sessions/:id falls through to the by-id scan, which returns all
six messages and the real cwd with it. So the detail panel resumes it with no cwd hint at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:30:06 +00:00
pastilhasandClaude Opus 5 ebfa09e067 live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations
somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it
is a list where the thing you are looking for is outnumbered.

Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it,
which hides at zero because a standing `0` there says nothing. These two say a lot at zero:
`Commanders (0)` is how you find out the watcher is not running, which is usually why you looked.

The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander
shares one directory, so the directory IS the answer, it costs no query, and it is already right during a
commander's first turn, before any row exists.

It has to be in the payload rather than computed in the panel, and that is the point of the change: the
iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name
them. A deploy commander appearing in that swipe order is precisely what must not happen.

A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders
it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to
disarm it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:25:17 +00:00
pastilhasandClaude Opus 5 6dae3e7a14 a commander's transcript was fetched, returned, and then ignored
The pane still showed "Send a message to start" over a bar reading 1 turn · $0.28. The id fix was
necessary and was not the cause.

useChat seeds its message list with useState(initialMessages ?? []) — read on the first render and never
again. ExistingCommander fetched the transcript AND called useChat in one component body, so the hook's
first render happened with resume still undefined and seeded itself empty. The messages landed a moment
later and the hook had no reason to look. The early `Loading…` return does not help: hooks run above it.

ChatPanelWrapper has always avoided this, and the line that does it is easy to read as a loading
nicety — `if (selectedSessionId && resuming) return null` sits ABOVE the component that owns the hook, so
the hook's first render is its only render, and it has real data. Split the same way here: a fetcher, and
a chat mounted once there is a transcript to mount it with.

Verified server-side while looking for this: loadClaudeSessionById returns the six messages for
172cdd8c, ending on the commander's answer. The data was always there and always reachable. Only the
pane could not see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:22:06 +00:00
pastilhasandClaude Opus 5 2379260ffe the commander pane threw its own conversation away at the end of the first turn
The messages were on screen, streaming, correct. Then the turn ended and the pane went empty under a
metadata bar still reading 1 turn · $0.66.

Nothing was lost — the pane discarded it. Once the transcript id was known, NewCommander navigated onto
it, and CommandHqDetail answered that URL with a DIFFERENT component: ExistingCommander, keyed on the id.
So React unmounted the live chat, closed its socket, and re-read the transcript from the API — a file the
harness had only just finished writing. What survived was whatever that fetch returned, which was nothing.

A permalink is for a refresh, not an instruction to reload. /chat has always understood that: useChat
replaces the URL and remounts nothing. This pane replaced the URL and remounted everything.

So the pane now keeps the session it started. CommandHqDetail remembers which id came from its own
composer and keeps that component mounted while the URL names it, swapping to the resuming component only
for a commander opened from the list. Clearing the memory on a real navigation means coming back later
still resumes from the transcript rather than remounting an empty live chat.

Worse than the empty pane, and the reason this is not cosmetic: the teardown fired at session:claude for
any chat where the harness names its transcript early — the START of a turn. A commander's turns run for
tens of minutes. The pane would have dropped the stream and shown a reader nothing at all while a deploy
rolled across seven servers.

claudeSessionId is now also set at session:claude, not only at result, matching the ref beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:10:54 +00:00
pastilhasandClaude Opus 5 21b893473d a new commander showed "Send a message to start" after answering correctly
The first deploy commander ran, cost $0.66, and reported exactly the right thing — and the pane showed
an empty state, because the page was fetching an id nothing knows.

Two ids for one commander. `session:init` sets `sessionId` to OFFICER's key and arrives first;
`session:claude` carries Claude's transcript id and arrives later. `fileAsCommander` files the row
under the transcript id, and `command_hq_sessions` is keyed on it. But NewCommander navigated on
`chat.sessionId` — the first one it could see — and then set `filed`, which blocked the correction when
the real id turned up. So the URL held officer's key, /api/chat/sessions/<that> 404'd twice, and the
transcript was unreachable from the UI while sitting perfectly readable on disk.

The transcript id was only ever in a REF, deliberately — the reconnect handshake reads it synchronously
— so writing it re-rendered nothing and no caller could react to it. It is now state as well, returned
alongside sessionId, with the ref kept for the handshake that needs it.

setCategory had the same fault and would have silently updated no row: it was keyed on officer's key
against a table keyed on the transcript id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:59:03 +00:00
pastilhasandClaude Opus 5 af78f0ed51 sessions carry isCommander, so the phone can filter what tabs would hide
A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the
two: commanders appeared in the chat list looking like conversations somebody started and forgot.

The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an
implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs
to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew
about would fix one surface and leave the other wrong — which is the shape of half the bugs found
tonight.

Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one
directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already
reads each session's cwd. So it costs one string comparison, adds no query, and still works for a
commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript
rather than from code and records whatever directory the process was actually in.

Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists,
no transcript has that cwd, and Command HQ shows 0 commanders.

Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:41:43 +00:00
pastilhasandClaude Opus 5 7a46612f41 the handoff watcher was built and never rendered
BackgroundJobDialog offers three fire behaviours and gates the third, `handoff` — exit, act, re-arm — on
an `isCommander` prop. Nothing ever passed it. So it defaulted to false everywhere and the option
appeared NOWHERE, including inside a Command HQ commander, where the dialog's own code makes it the
default.

Everything else was there: the fire rule, `handoffContract`, `armWatcher` reaching the server, and a note
recording that it had run for hours self-re-arming without failure. Only the radio button was missing,
which made a working mechanism look unbuilt — and it is the one a deploy watcher needs, since
`keep-going` never hands back to an agent at all.

Found while telling the owner how to start the deploy commander: the dialog in a commander showed two
options where I had said there would be three.

The fix is two lines and no new concept. `useEmbeddableChat` already receives `context` and passed it
into `useChat`; it now also returns it, and InputArea gives the dialog `isCommander={context ===
'command-hq'}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:30:14 +00:00
pastilhasandClaude Opus 5 240d8adbd3 a release is a tag, and the lock that stops a second deploy is local
Three changes, all from the owner's call.

The trigger is a tag, deploy/<version>, and nothing else. The code word it replaces could be written by
accident — every document about deploying contains it, this repository included, and so does every
conversation planning one. Defending that needs a rule about where in the subject line it sits, and a
rule like that is one somebody eventually gets wrong. A tag cannot be typed into prose, it names its own
version, and git ls-remote answers "is there one" without fetching.

The record of a deploy is LOCAL — data/deploys/<version>.done — not the pushed report. This is the part
that matters: making the report the marker means a failed push leaves the release looking undeployed and
the next tick rolls it out again. Pushes here fail for reasons unrelated to the rollout — gitea's public
SSH is rate-limited because every connection arrives from one proxy container IP that bots keep
penalised, which cost several retries tonight. So the lock cannot fail for a network reason, and the
report is an artefact: valuable, not load-bearing. Under data/ so a pull cannot clobber it.

And the commander skips the host it runs on ENTIRELY, naming it in the report. Restarting only officer
and leaving its own sidecar behind would work and is worse — one host in a state nobody chose, a step
behind, found later. The owner restarts it deliberately as the last step.

Tonight's deploy is backfilled: the lock file exists and deploy/2026.08.21.01 is tagged at bd41dff5, so
the mechanism has a precedent rather than starting from nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:21:09 +00:00
pastilhasandClaude Opus 5 8662f94b3b seven platforms, and the login is per host
deadpool-rezio-bridge joins the list: 100.64.0.21, checkout /home/br/officerdev/platform, deployed by
hand and verified — 7/7 processes online, username NOT NULL, uq_users_username_lower present, HEAD at
bd41dff5.

Its login is `br` and always will be. That makes the SSH user a per-host fact rather than a constant,
so the table carries it and the deploy prompt reads it. Worth stating plainly because the failure is
misleading: an orchestrator assuming `pastilhas` gets "Permission denied (publickey)", which reads as a
broken key rather than a wrong username.

Also folded in what tonight established. Three hosts answer to names that disagree with
`tailscale status` — edge-bridge and gitea-bridge are the old ones — so the table says which to use.
`edge-bridge` was listed as "not SSH-reachable" and that was wrong: its SSH was down at the moment I
checked, not absent. The pm2/nvm PATH trap is written in, including that `-lc` is not enough and cost
two wrong conclusions, and that macbookpro must not be helped with an exported PATH because doing so
breaks pm2. And a dirty tree means TRACKED changes; plain --porcelain skipped a host over a stray file.

officer.rezio.dev is recorded under "known wrong, waiting on someone else": it points at gateway rather
than deadpool, which is worse than unavailable — it will serve the wrong box the moment gateway answers
on 443.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:58:46 +00:00
14 changed files with 508 additions and 122 deletions
+33
View File
@@ -0,0 +1,33 @@
# 2026.08.21.02
Command HQ made usable: a commander's transcript renders, and Live separates commanders from the
conversations somebody is having.
Frontend and one chat API route. **No schema change, no dependency change** — verified against
`deploy/2026.08.21.01..HEAD`: nothing touched `src/schema.ts`, `package.json` dependencies or `bun.lock`.
So there is no `db:push` and no `bun install` in this plan, and a host that runs one has run something
this release did not ask for.
## What is in it
- Three separate defects that each made a commander's conversation invisible: the pane navigated to
officer's session key instead of Claude's transcript id, then remounted and discarded the live chat at
the end of the first turn, then seeded `useChat` with messages that had not arrived yet.
- Live splits into `Agents (n)` / `Commanders (n)`, and `/chat/live` now carries `isCommander` so a client
with no room for tabs — the iPhone app's swipe order — can filter on it.
- The handoff watcher's controls, which were built and never rendered.
## Every host
In the order the procedure gives. Nothing here is host-specific.
1. `git pull --ff-only`
2. `bun run plugins:update`
3. `pm2 restart officer`
**`officer` only.** No sidecar changed. Restarting `officer` rebuilds the SPA on boot, which is what
ships the frontend — there is no separate build step to run.
## Then check
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else should have restarted.
+8 -2
View File
@@ -11,7 +11,13 @@ deploy/
The plan is written by hand before the release commit. The report is written by the deploy commander
afterwards and pushed back here.
**The report's presence is what marks a release deployed.** That is the whole idempotence mechanism: a
watcher that restarts checks for the report before acting, so it cannot roll the same release out twice.
**A release is a tag**, `deploy/<version>`, and nothing else triggers a deploy — not a commit message,
not a branch. A tag cannot be written into prose by accident, which a code word can and which every
document about deploying does.
**What marks a release deployed is LOCAL**: `$OFFICER_ROOT/data/deploys/<version>.done`. Not the report.
If the lock were the pushed report, a failed push would mean the release looked undeployed and the next
tick would roll it out again — and pushes here fail for unrelated reasons. So the report is an artefact,
valuable and not load-bearing.
See `prompts/deploy-commander.md`.
+83 -37
View File
@@ -4,59 +4,105 @@ Every machine the owner runs, how to reach it, and what of Officer is on it. Kep
orchestrator ("pull and update every platform", "restart officer everywhere", "which host is behind")
has one place to read rather than a fact re-derived per session.
**Started 2026-08-21.** Everything below was verified from `officer-dev` on that date by SSH and
`pm2 jlist`, except where marked assumed. Commit SHAs are a snapshot and go stale immediately — they
are here to show which checkout is live, not as a status board.
**Verified 2026-08-21** from `officer-dev` by SSH and `pm2 jlist`, except where marked assumed.
## How access works
All of it is over the tailnet. There is **no `~/.ssh/config`** — names resolve by MagicDNS and the
login is key-based, so `ssh <name>` as `pastilhas` is the whole story:
All of it is over the tailnet, key-based, no `~/.ssh/config`, no passwords, no jump hosts, no ports.
**The login user is NOT always `pastilhas`.** It is per host and it is in the table below. `deadpool` runs
as `br` and always will — the owner's key is authorised for that account. An orchestrator that hard-codes
a username fails on that host and will fail on the next one like it.
```bash
ssh alpha
ssh fofos
ssh alpha # pastilhas, implied
ssh br@deadpool-rezio-bridge # not implied
```
Nothing here needs a password, a jump host or a port. A host that refuses port 22 is not
misconfigured — see "Not SSH-reachable" below.
## Officer platforms
| Host | Tailnet IP | Live checkout | Runs `officer` | Git remote |
| --- | --- | --- | --- | --- |
| `officer-dev` | 100.64.0.13 | `~/officer-dev/platform` | yes (this host) | `git@gitea.officer.dev:officerdev/platform.git` |
| `alpha` | 100.64.0.1 | `~/officerdev/platform` | yes | `git@gitea.officer.dev:officerdev/platform.git` |
| `fofos` | 100.64.0.17 | `~/officerdev/platform` | yes | `https://gitea.officer.dev/officerdev/platform.git` |
| `headscale` | 100.64.0.2 | `~/dockers/officerdev/platform` | no | `ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git` |
| `omega` | 100.64.0.3 | `~/projects/officerdev/platform` | no | none set |
Seven, all running.
Three things an orchestrator has to handle, all verified and none of them cosmetic:
| Host | Tailnet IP | Login | Checkout | Domain |
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
- **The checkout path differs per host.** There is no single path to hard-code. Take it from PM2 —
`pm2 jlist` gives the `officer` process's `pm_cwd`, which is the checkout that is actually serving.
- **`alpha` has a SECOND, stale checkout** at `~/dockers/officer.dev/platform` (same remote, different
HEAD). Pulling the wrong one updates nothing and looks like success. PM2's `pm_cwd` is the tiebreak.
- **The remotes are not the same URL.** `fofos` pulls over HTTPS and `headscale` points at a different
Gitea entirely (`gitea.pastilhas.dev:2222`). An orchestrator that assumes SSH to `gitea.officer.dev`
breaks on two of five.
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
`headscale` and `omega` hold a checkout but run no `officer` process (no PM2 on either). Whether those
are retired, staging or half-installed is **not established** — ask before treating them as targets.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
| In `tailscale status` | Also | The machine's own hostname |
| ----------------------- | ---------------------------- | -------------------------- |
| `edge-bridge` | `edge-pertento-bridge` | `edge-pertento` |
| `gitea-bridge` | `production-pertento-bridge` | `PPROD` |
| `deadpool-rezio-bridge` | — | `deadpool` |
The middle column is what to use. `edge-bridge` and `gitea-bridge` are the old names and are being
retired; anything reading `tailscale status` will still see them.
### Things that will catch out an orchestrator
All verified, none cosmetic.
- **The checkout path differs per host** — four distinct shapes across seven machines. Take it from PM2:
the `officer` process's `pm_cwd`. `alpha` carries a SECOND, stale checkout at
`~/dockers/officer.dev/platform`, and pulling that one succeeds while changing nothing.
- **`pm2` is not on a non-interactive PATH.** It lives under nvm, and nvm's setup is in `.zshrc`, which a
_login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is **not** enough — it
reports "command not found" on machines where pm2 is plainly running, which cost two wrong conclusions
on 2026-08-21.
- **On `macbookpro`, do not help the PATH.** Exporting one to reach `bun` breaks `pm2`, which is only on
the interactive shell's. `$SHELL -ic` and nothing else.
- **The git remotes differ.** `fofos` pulls over HTTPS; the others over SSH. Never assume a URL — pull in
place.
- **A "dirty tree" means TRACKED changes.** `git status --porcelain` counts untracked files, and a stray
script a fast-forward could never touch is not a reason to skip a host. Use `--untracked-files=no`.
- **`macbookpro` is a laptop.** It may be asleep. Unreachable is a skip with a note, not a failed deploy.
## Not platforms — ignore for now
A checkout may be present; no `officer` process runs on any of these. Whether they are retired, staging
or half-installed is **not established** — ask before treating them as targets.
| Host | Tailnet IP |
| ----------- | ---------- |
| `headscale` | 100.64.0.2 |
| `omega` | 100.64.0.3 |
| `gateway` | 100.64.0.4 |
## Other machines on the tailnet
- `gateway` (100.64.0.4) — reachable over SSH as `pastilhas`, no platform checkout, no PM2.
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `edge-bridge` (100.64.0.15),
`gitea-bridge` (100.64.0.12), `officer-bridge` (100.64.0.5). Assumed to be service-only
containers rather than hosts to log into — not confirmed.
- **Client devices, not servers:** `macbookpro`, `iphone16`, `andres-ipad`, `pixel9a`, plus other
people's devices in the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`,
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `officer-bridge` (100.64.0.5).
Assumed service-only containers — not confirmed. `edge-bridge` was listed here until 2026-08-21 and was
wrong: its SSH was simply down at the time of checking.
- **Client devices, not servers:** `iphone16`, `andres-ipad`, `pixel9a`, plus other people's devices in
the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`, `fedras-iphone`,
`guilhermes-macbook-pro`, `macbook-pro-de-joo`).
## Known wrong, waiting on someone else
- **`officer.rezio.dev` resolves to 100.64.0.4** — that is `gateway`, not `deadpool` (100.64.0.21). Not
merely unavailable: pointed at a different machine, so it will serve the wrong box the moment gateway
answers on 443. Ports 80 and 443 are closed on `deadpool`, so nothing fronts it yet. Waiting on the
domain owner.
## Keeping this list true
It is a running list, so it is worth saying what makes it rot: a new server, a moved checkout, a
platform retired, or a remote changed. Re-verify with the same three commands the list was built from
— `tailscale status`, `pm2 jlist` for the `officer` entry's `pm_cwd`, and `git -C <cwd> remote get-url
origin`.
It rots when a server is added, a checkout moves, a platform is retired, a remote changes or a login
differs. Re-verify with the four commands it was built from:
```bash
tailscale status
ssh <login>@<host> '$SHELL -ic "pm2 jlist"' # the officer entry's pm_cwd
ssh <login>@<host> 'git -C <cwd> remote get-url origin'
getent hosts <domain>
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.21.01",
"version": "2026.08.21.02",
"private": true,
"type": "module",
"workspaces": [
+68 -20
View File
@@ -15,30 +15,52 @@ You are not a loop. Something fires you, and each time you fire you do exactly o
there is a release to deploy that has not been deployed.
```bash
cd ~/officer-dev/platform && git fetch --quiet origin HEAD
git log --oneline HEAD..FETCH_HEAD | grep DEPLOY_NOW
git ls-remote --tags origin 'refs/tags/deploy/*'
```
- **Nothing matching** → say "no release pending" and stop. Do not pull, do not touch a server.
- **A commit whose subject begins `DEPLOY_NOW`** → that is the release. Continue.
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
a commit message, not a branch, not a push.
`DEPLOY_NOW` must be at the **start of the subject line**, not merely present. This document contains the
word repeatedly and must never trigger a deploy; so must any commit that discusses one.
**Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
about deploying contains it, this file included, and so does every conversation planning one — so the
trigger has to be defended with rules about where in the subject line it sits, and a rule like that is
one somebody eventually gets wrong. A tag cannot be typed into prose. It is created deliberately, it
names its own version, and `git ls-remote` answers "is there one" in a single call without fetching.
**Already deployed?** A release is deployed when `deploy/<version>.report.md` exists on `origin`. Check
before acting. A watcher that restarts must not redeploy, and this is the only thing standing between it
and doing so.
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
- **A `deploy/*` tag with no local record** → that is the release. Continue.
---
### The record is LOCAL, not in git
```
$OFFICER_ROOT/data/deploys/<version>.done
```
That file's existence means deployed. Written after the rollout, before the report is pushed.
**This is deliberate and it is the important part.** The obvious design makes the pushed report the
marker — and then a failed `git push` means the release looks undeployed and the next tick **does it all
again**. Pushes here are genuinely unreliable: gitea's public SSH is rate-limited because every
connection arrives from one proxy container IP and internet bots get it penalised, which cost several
retries on 2026-08-21.
So the lock is local and cannot fail for a network reason. The report is an ARTEFACT — valuable, and not
load-bearing. A missing report is a missing record; a missing lock is a second deploy.
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
clone does not arrive believing it has deployed nothing.
## The release
The triggering commit carries everything:
The tag names the version. The commit it points at carries the rest:
- `package.json` — `version` bumped to `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps' scheme. `NN`
increments for a second deploy on the same day.
- `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
scheme. `NN` increments for a second deploy on the same day.
- `deploy/<version>.md` — **the plan**. What to run, in order, and on which hosts. Written by hand.
If the tag and `package.json` disagree, **stop and say so**. One of them is a mistake and guessing which
is how the wrong tree gets deployed.
If the plan file is missing, **stop and say so**. A release with no plan is not a release you can guess
at, and guessing is how a database gets a schema it was not ready for.
@@ -49,8 +71,13 @@ at, and guessing is how a database gets a schema it was not ready for.
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
because a copy rots the day a server moves and the copy is always the one somebody trusts.
That document holds every machine, its tailnet name, its checkout and whether a platform runs on it. Read
it at the start of every deploy, and treat the hosts marked as having no platform as out of scope.
That document holds every machine, its tailnet name, **its login user**, its checkout and whether a
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
Four things that will catch you out, all verified rather than assumed:
@@ -71,8 +98,10 @@ Four things that will catch you out, all verified rather than assumed:
Per host, in this order. Do them **one host at a time** and keep a record as you go — a report you
assemble at the end from memory is a report that will be wrong about the host that failed.
1. **Refuse a dirty tree.** `git status --porcelain` — if anything is uncommitted, skip this host and say
so. Never discard someone's work to ship a release.
1. **Refuse a dirty tree — TRACKED changes only.** `git status --porcelain --untracked-files=no`. Plain
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
@@ -89,13 +118,32 @@ assemble at the end from memory is a report that will be wrong about the host th
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
one that stopped in the middle and said nothing about the four hosts it never reached.
### Skip the host you are running on
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
You are a session inside `officer-claude-code` on that machine. Restarting it ends you mid-rollout —
which is not a hypothetical: on 2026-08-21 a manual deploy reached its own host last and had to leave
that one sidecar alone to survive writing its own report.
Restarting only `officer` and leaving your own sidecar behind would work, and it is worse: it puts one
host in a state nobody chose, a step behind the others, discovered later. So skip the whole host and say
which one. The owner restarts it deliberately, as the last step, once they are happy with the rest.
Find it with `hostname` and match it against the list — do not assume you are on any particular one.
---
## The report
Commit `deploy/<version>.report.md` to the platform repository and push it. Subject line: the version and
the outcome, **without the code word** — a report is not a release, and a report that triggers a deploy
would loop forever.
Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
`deploy/<version>.report.md` and push it.
That order matters. The record is what stops a second deploy, and the push is the part that can fail for
reasons having nothing to do with the rollout. **If the push fails, say so and stop — do not retry into a
loop and do not treat it as a failed deploy.** The deploy succeeded; its paperwork is pending.
Never tag the report. A tag is the trigger, and a report that tags itself deploys itself forever.
Per host: what its HEAD was before and after, which steps ran, which were skipped and why, which
processes restarted, and whether they came back. Then one line at the top saying how many hosts
+19 -1
View File
@@ -27,6 +27,7 @@ import {
stopBackgroundTask,
claudeSessionContext,
liveSessionTitle,
isCommandersDir,
} from './claude-sessions';
import {
listOpenCodeSessions,
@@ -287,7 +288,21 @@ chatRouter.get('/live', async (ctx) => {
// genuinely not been written yet.
const transcriptId = session.claudeSessionId;
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
const cwd = resolved?.cwd ?? null;
return {
...session,
harness: 'claude' as const,
title: resolved?.title ?? null,
cwd,
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
// history listing does it: every commander shares one directory, so the directory IS the answer and
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
//
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
// deploy commander turning up in that swipe order is exactly what it must not do.
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
};
});
// OpenCode rows carry less, and the shape says so rather than faking parity. `isGenerating` is always
@@ -326,6 +341,9 @@ chatRouter.get('/live', async (ctx) => {
// shown a live row without a name.
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
cwd: meta?.cwd || null,
// Always false rather than absent: a commander is started through Command HQ, which runs claude.
// Stating it keeps the two row shapes answerable by one client filter.
isCommander: false,
};
});
+40 -2
View File
@@ -1,5 +1,5 @@
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { dirname, join, resolve } from 'node:path';
import { DATA_PATH, getCommandersDir } from '../../data-path';
import {
appendTextAs,
findPathAs,
@@ -108,6 +108,24 @@ export type ClaudeSessionSummary = {
* one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
*/
partCount?: number;
/**
* This is a Command HQ commander, not an ordinary chat.
*
* ── Why it is a field and not a frontend detail ──
*
* A commander IS a Claude session, and this list reads transcripts from disk, so nothing distinguished
* the two and commanders appeared in the chat list looking like conversations somebody started and
* forgot. The web can now split them into tabs — but the reason this belongs in the PAYLOAD is the
* phone: the iPhone chat app swipes left and right between live chats, and with no tabs to hide behind
* it has to be able to filter commanders out of that gesture itself. A flag only the web knew about
* would fix one surface and leave the other wrong.
*
* Derived from the CWD rather than from a database join. Every commander a user has shares one
* directory — `getCommandersDir(email)`, never per-commander and never client-chosen — and this list
* already reads each session's `cwd`. So it costs a string comparison, works for a commander whose
* `command_hq_sessions` row was lost, and adds no query.
*/
isCommander?: boolean;
};
/** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */
@@ -968,11 +986,31 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
// Every commander a user has lives in ONE directory, so this is a property of the group rather than of
// each transcript — computed once here instead of per row. See `isCommander` on the summary type for
// why the flag has to reach the payload at all.
const commander = isCommandersDir(who.email, cwd);
return mergeChains(scanGroup(who, cwd))
.map(publish)
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* Is this directory the one every commander shares?
*
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
* whatever the process was actually in.
*/
export function isCommandersDir(email: string, cwd: string): boolean {
try {
return resolve(cwd) === resolve(getCommandersDir(email));
} catch {
return false;
}
}
/**
* Every conversation in every directory, newest first.
*
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
import { resolve } from 'node:path';
import { isCommandersDir } from './claude-sessions';
import { getCommandersDir } from '../../data-path';
const EMAIL = 'pastilhas@officer.dev';
// The live payload's `isCommander` is derived from a cwd read out of a transcript, so it is whatever the
// process was actually in — not a path this code composed. These are the shapes that arrive.
describe('isCommandersDir', () => {
const dir = getCommandersDir(EMAIL);
it('recognises the commanders directory', () => {
expect(isCommandersDir(EMAIL, dir)).toBe(true);
});
it('survives a trailing slash and a .. hop', () => {
expect(isCommandersDir(EMAIL, `${dir}/`)).toBe(true);
expect(isCommandersDir(EMAIL, `${dir}/sub/..`)).toBe(true);
});
it('rejects a sibling and a child', () => {
expect(isCommandersDir(EMAIL, resolve(dir, '..'))).toBe(false);
expect(isCommandersDir(EMAIL, resolve(dir, 'nested'))).toBe(false);
});
// A commander's cwd belongs to the account that owns it. Reading one user's directory as another's
// would file their commanders under the wrong person and, in the iPhone swipe order, show them.
it('is per account', () => {
expect(isCommandersDir('someone-else@officer.dev', dir)).toBe(false);
});
it('never throws on rubbish', () => {
expect(isCommandersDir(EMAIL, '')).toBe(false);
expect(isCommandersDir(EMAIL, '\0')).toBe(false);
});
});
@@ -575,6 +575,10 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
}, [autoSend, isConnected, messages.length, input]);
return {
// Surfaced so the composer can offer what only makes sense here. The Command HQ context is the one
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
// composer, so the option was never rendered anywhere at all.
context: params.context,
messages,
historicalCount,
streamingText,
@@ -173,6 +173,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
open={jobDialogOpen}
onOpenChange={setJobDialogOpen}
cwd={cwd}
/*
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
promise the platform has to keep rather than only a sentence in the prompt.
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
server. Only the radio button was missing, so the feature looked unbuilt.
*/
isCommander={manager.context === 'command-hq'}
onSubmit={(intent, preamble, armWatcher) => {
// Claimed by the next job to start, which is how the preview tells this one apart from the
// background jobs an agent starts on its own.
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { MessageSquare, Plus, RefreshCw } from 'lucide-react';
import { MessageSquare, Plus, Radar, RefreshCw } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
@@ -47,6 +47,8 @@ const TABS: { value: View; label: string }[] = [
localStorage also happens to be the right scope: a phone and a desktop wanting different tabs is reasonable.
*/
const VIEW_KEY = 'chat.sessions.view';
/* Which half of Live is showing. Same store and the same reasoning as the tab above it. */
const LIVE_KIND_KEY = 'chat.sessions.liveKind';
const EXPANDED_KEY = 'chat.sessions.expanded';
export const SessionList = () => {
@@ -273,8 +275,24 @@ export const SessionList = () => {
few seconds before the harness announces one, and a row that navigates nowhere is worse than one that
plainly cannot be clicked.
*/
type LiveKind = 'agents' | 'commanders';
const LIVE_TABS: { value: LiveKind; label: string }[] = [
{ value: 'agents', label: 'Agents' },
{ value: 'commanders', label: 'Commanders' },
];
const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => {
const { live: sessions, isLoading, error, refetch } = useLiveSessions();
const [kind, setKind] = useLocalStorageState<LiveKind>(LIVE_KIND_KEY, 'agents');
/*
Split, not filtered-on-demand, because both counts are always on screen — the tabs exist to be
compared, so both numbers have to be right whichever one is showing.
*/
const agents = useMemo(() => sessions.filter((s) => !s.isCommander), [sessions]);
const commanders = useMemo(() => sessions.filter((s) => s.isCommander), [sessions]);
const shown = kind === 'commanders' ? commanders : agents;
if (error) {
return (
@@ -293,22 +311,49 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
);
}
if (isLoading && sessions.length === 0) return <LoadingBlock label="Asking the agent…" />;
if (!sessions.length) {
return (
<EmptyBlock
icon={MessageSquare}
title="Nothing running"
hint="Conversations with an agent behind them right now show up here."
/>
);
}
return (
<DataList>
{sessions.map((session) => (
<LiveRow key={session.sessionKey} session={session} activeId={activeId} home={home} />
))}
</DataList>
<div className="flex min-h-0 flex-1 flex-col">
{/* Counts shown even at zero, unlike the badge on the Live tab above. That badge answers "is anything
running", where a standing `0` says nothing. These two answer "which kind, and how many", and the
empty one is half the answer — `Commanders (0)` tells you the watcher is not running, which is
the thing you came to check. */}
<Tabs value={kind} onValueChange={(v) => setKind(v as LiveKind)} className="shrink-0 px-2 pt-2">
<TabsList className="flex h-auto w-full items-center gap-1 bg-transparent p-0 text-muted-foreground">
{LIVE_TABS.map((tab) => {
const count = tab.value === 'commanders' ? commanders.length : agents.length;
return (
<TabsTrigger
key={tab.value}
value={tab.value}
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{tab.label}
<span className={`ml-1 tabular-nums ${count > 0 ? 'text-success' : 'opacity-50'}`}>({count})</span>
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
{shown.length === 0 ? (
<EmptyBlock
icon={kind === 'commanders' ? Radar : MessageSquare}
title={kind === 'commanders' ? 'No commander running' : 'Nothing running'}
hint={
kind === 'commanders'
? 'A commander appears here while it is taking a turn — including a turn nobody asked for.'
: 'Conversations with an agent behind them right now show up here.'
}
/>
) : (
<DataList>
{shown.map((session) => (
<LiveRow key={session.sessionKey} session={session} activeId={activeId} home={home} />
))}
</DataList>
)}
</div>
);
};
@@ -322,6 +367,14 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
*/
const LiveRow = ({ session, activeId, home }: { session: LiveSession; activeId?: string; home: string | null }) => {
const transcriptId = session.claudeSessionId;
// A commander opens HERE, in /chat, like any other row.
//
// This briefly linked to Command HQ, on the argument that /chat renders the transcript without the
// category, cost, watcher state or the disarm control. That argument loses to a simpler one: sending
// somebody out of the screen they are reading, into a different route with a different layout, is a
// bigger loss than the metadata they did not ask for. A commander IS a chat — the same harness, the same
// transcript, resumable by the same route. Command HQ is where you go to MANAGE one, which is a separate
// errand from reading what it just said.
return (
<DataRow
to={transcriptId ? chatSessionPath(transcriptId) : undefined}
@@ -33,35 +33,77 @@ function EmptyState() {
}
/**
* Starts a brand-new commander. `useChat` mints the transcript id on the harness's first response
* (`session:claude`) — once that lands, the URL is replaced onto the real id so a refresh resumes the
* same conversation instead of starting a second one. `replaceUrl: false` because `useChat`'s own permalink
* always writes `/chat/<id>` (see `writePermalink` in `useChat.ts`), which is the wrong screen here.
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
*
* `useChat` mints the transcript id on the harness's first response (`session:claude`); once that lands the
* URL is replaced onto it so a refresh resumes this conversation instead of starting a second one.
* `replaceUrl: false` because `useChat`'s own permalink always writes `/chat/<id>` (see `writePermalink`),
* which is the wrong screen here.
*
* **The URL change must not remount anything.** It used to: the pane swapped to `ExistingCommander`, which
* tore down the live websocket and re-read the transcript from the API. The whole conversation vanished at
* the end of the first turn — the messages were in memory, the replacement read a file still being
* written, and what the owner saw was an empty composer under a metadata bar reading `1 turn · $0.66`.
* Tearing down mid-turn is worse than it looks with a commander, whose turns are long: the pane would drop
* the stream and the reader would watch nothing happen for twenty minutes.
*
* So this component holds the session from the composer through to the end, exactly as `/chat` does — a
* permalink is for a refresh, not an instruction to reload. `CommandHqDetail` keeps it mounted by
* remembering which id started here.
*/
function NewCommander({ category }: { category: number | 'none' | undefined }) {
function NewCommander({
category,
onStarted,
}: {
category: number | 'none' | undefined;
onStarted: (id: string) => void;
}) {
const navigate = useNavigate();
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
const { setCategory } = useCommandHqCommanderActions(chat.sessionId ?? '');
// Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key
// updates nothing and says nothing. Same root cause as the navigate below.
const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? '');
const [filed, setFiled] = useState(false);
useEffect(() => {
if (!chat.sessionId || filed) return;
// `claudeSessionId`, NOT `sessionId`. The commander is filed under Claude's transcript id
// (`fileAsCommander` in websocket.ts), and this URL has to match or the detail pane fetches an id
// nothing knows. `sessionId` arrives FIRST — `session:init` sets it to officer's own key — so
// navigating on it sent the page to an id that 404s, and `filed` then blocked the correction when the
// real one arrived. A commander that had run and answered correctly rendered as "Send a message to
// start". Waiting for the transcript id costs one more round trip and is the only id that works.
if (!chat.claudeSessionId || filed) return;
setFiled(true);
// `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was
// requested from here. The same race the `/commanders/new` HTTP route resolves server-side by always
// running its UPDATE last; done client-side here for the same reason, since the web composer goes
// through the websocket path instead.
if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
navigate(commandHqSessionPath(chat.sessionId, category), { replace: true });
onStarted(chat.claudeSessionId);
navigate(commandHqSessionPath(chat.claudeSessionId, category), { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat.sessionId, filed]);
}, [chat.claudeSessionId, filed]);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80">
New commander
</div>
<EmbeddableChat chat={chat} sessionId={undefined} className="min-h-0 flex-1" />
{chat.claudeSessionId ? (
<MetaBar
sessionId={chat.claudeSessionId}
title={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
) : (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80">
New commander
</div>
)}
{/* `context` is a prop of EmbeddableChat, and it is NOT the `context` handed to `useChat` above.
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
</div>
);
}
@@ -220,6 +262,50 @@ type ResumeState = {
title: string | null;
};
/**
* The chat itself, mounted only once the transcript is in hand and keyed on the session.
*
* Split from the fetch above, and that split is the whole point. `useChat` seeds its message list with
* `useState(initialMessages ?? [])` — read on the FIRST render and never again. So a component that
* fetches and calls `useChat` in one body seeds itself empty, because on that first render the fetch has
* not landed. The early `Loading…` return does not save it: hooks run before it. The messages arrive, the
* hook ignores them, and a commander that had run and answered showed "Send a message to start" over a
* metadata bar reading `1 turn · $0.28`.
*
* `ChatPanelWrapper` has always done it this way — `if (selectedSessionId && resuming) return null` sits
* ABOVE the component that owns the hook, so the hook's first render is its only render with real data.
* Same shape here.
*/
function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: ResumeState }) {
const chat = useChat(undefined, resume.model ?? undefined, {
replaceUrl: false,
context: 'command-hq',
resumeSessionId: sessionId,
initialMessages: resume.messages,
paginate: { sessionId, total: resume.total, initialOffset: resume.offset },
});
return (
<div className="flex h-full flex-col">
<MetaBar
sessionId={sessionId}
title={resume.title ?? undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
<EmbeddableChat
chat={chat}
sessionId={undefined}
context="command-hq"
panelId={`command-hq:${sessionId}`}
className="min-h-0 flex-1"
/>
</div>
);
}
/** Fetches the transcript and nothing else. Mounts the chat only when it has one. */
function ExistingCommander({ sessionId }: { sessionId: string }) {
const client = useClient();
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
@@ -252,40 +338,11 @@ function ExistingCommander({ sessionId }: { sessionId: string }) {
};
}, [sessionId]);
const chat = useChat(undefined, resume?.model ?? undefined, {
replaceUrl: false,
context: 'command-hq',
...(resume
? {
resumeSessionId: sessionId,
initialMessages: resume.messages,
paginate: { sessionId, total: resume.total, initialOffset: resume.offset },
}
: {}),
});
if (resuming || !resume) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading…</div>;
}
return (
<div className="flex h-full flex-col">
<MetaBar
sessionId={sessionId}
title={resume.title ?? undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
<EmbeddableChat
key={sessionId}
chat={chat}
sessionId={undefined}
panelId={`command-hq:${sessionId}`}
className="min-h-0 flex-1"
/>
</div>
);
return <ResumedCommander key={sessionId} sessionId={sessionId} resume={resume} />;
}
export const CommandHqDetail = () => {
@@ -293,7 +350,18 @@ export const CommandHqDetail = () => {
const [searchParams] = useSearchParams();
const category = categoryFromParam(searchParams.get('category'));
// The id of a commander started in this pane. While the URL still names it, `NewCommander` stays mounted
// and keeps its live socket — swapping to `ExistingCommander` here is what used to make the conversation
// disappear the moment the URL was replaced. Cleared as soon as the owner opens a different commander,
// so coming back later resumes from the transcript properly instead of showing an empty remount.
const [startedHere, setStartedHere] = useState<string | null>(null);
useEffect(() => {
if (startedHere && sessionId !== 'new' && sessionId !== startedHere) setStartedHere(null);
}, [sessionId, startedHere]);
if (!sessionId) return <EmptyState />;
if (sessionId === 'new') return <NewCommander category={category} />;
if (sessionId === 'new' || sessionId === startedHere)
return <NewCommander category={category} onStarted={setStartedHere} />;
return <ExistingCommander key={sessionId} sessionId={sessionId} />;
};
@@ -185,6 +185,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// `session:claude` (start of turn) and `result` (end), so a chat started in this tab becomes
// reattachable the moment the harness names its transcript rather than when the turn finishes.
const claudeSessionIdRef = useRef<string | null>(resumeSessionId ?? null);
/**
* The same value as state, for callers that have to RENDER or navigate on it.
*
* The ref above cannot serve that: it is deliberately a ref so the reconnect handshake can read it
* synchronously, which means writing it triggers no re-render and nothing outside can react to it.
*
* Command HQ needed exactly that and could not have it. `NewCommander` navigates once the transcript
* id is known, so a refresh resumes the same conversation — but the only id it could see was
* `sessionId`, which `session:init` sets to OFFICER's key. So it navigated to the wrong id, the
* commander was filed under the transcript id, and every fetch for that page 404'd: a commander that
* had genuinely run, cost money and answered correctly showed "Send a message to start".
*/
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
const client = useClient();
@@ -328,6 +341,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// here rather than at `result` is the difference between "refresh mid-turn and lose the turn" and
// "refresh mid-turn and watch it carry on".
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
break;
@@ -391,6 +405,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// (`msg.sessionId || randomUUID()`) that `/chat/sessions/:id` cannot resolve.
if (msg.claudeSessionId) {
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
}
commitStreaming();
@@ -830,6 +845,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
isConnected,
isGenerating,
sessionId,
/** Claude's own transcript id — what a commander is filed under, and what a permalink must use. */
claudeSessionId,
model,
selectedModel,
hasStarted,
@@ -26,6 +26,14 @@ export type LiveSession = {
*/
title: string | null;
cwd: string | null;
/**
* A commander rather than an ordinary chat, derived server-side from the cwd.
*
* Every consumer of this list needs it, and one of them cannot work around its absence. The sessions
* panel splits Live into two tabs; the iPhone app swipes between live chats, has no tabs, and so can only
* keep a deploy commander out of that swipe order if the payload names it.
*/
isCommander: boolean;
};
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';