Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3343b21a17 | ||
|
|
aac8af5e1f | ||
|
|
8be09a012b | ||
|
|
55ecbf4fb8 | ||
|
|
926ce86b83 | ||
|
|
e1ad680475 | ||
|
|
ebfa09e067 | ||
|
|
6dae3e7a14 | ||
|
|
2379260ffe | ||
|
|
21b893473d | ||
|
|
af78f0ed51 | ||
|
|
7a46612f41 | ||
|
|
240d8adbd3 | ||
|
|
8662f94b3b |
@@ -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.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 2026.08.21.02 — deployed
|
||||||
|
|
||||||
|
**6/6 hosts.** Every platform the plan reaches is on `55ecbf4f`, `officer` restarted and back `online`,
|
||||||
|
and no other process on any host was touched by the restart step.
|
||||||
|
|
||||||
|
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this commander runs
|
||||||
|
on. See below.
|
||||||
|
|
||||||
|
Rolled out one host at a time from `officer-dev`, by the procedure in `prompts/deploy-commander.md`.
|
||||||
|
|
||||||
|
## The release
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.21.02` → `55ecbf4f`; `package.json` says `2026.08.21.02`. They agree.
|
||||||
|
Plan: `deploy/2026.08.21.02.md` — pull, `plugins:update`, restart `officer` only. No `bun install`,
|
||||||
|
no `db:push`, and neither was run anywhere.
|
||||||
|
|
||||||
|
| Host | Before | After | plugins | restarted | restart_time | back |
|
||||||
|
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
|
||||||
|
| `alpha` | `eee73e77` | `55ecbf4f` | 12/12 | officer | 60 → 61 | online |
|
||||||
|
| `fofos` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 15 → 16 | online |
|
||||||
|
| `macbookpro` | `eee73e77` | `55ecbf4f` | none installed | officer | 2 → 3 | online |
|
||||||
|
| `edge-pertento-bridge` | `eee73e77` | `55ecbf4f` | 2/2 | officer | 4 → 5 | online |
|
||||||
|
| `production-pertento-bridge` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 6 → 7 | online |
|
||||||
|
| `deadpool-rezio-bridge` | `bd41dff5` | `55ecbf4f` | none installed | officer | 0 → 1 | online |
|
||||||
|
| `officer-dev` | `55ecbf4f` | `55ecbf4f` | not run | **none** | 155 | skipped |
|
||||||
|
|
||||||
|
Every host was clean of TRACKED changes and every pull was a real fast-forward. `pm2 jlist` after each
|
||||||
|
restart showed zero processes in any state other than `online`, so nothing was collaterally bounced.
|
||||||
|
|
||||||
|
Checkout paths were taken from each `officer` process's `pm_cwd`, not assumed — so `alpha`'s stale
|
||||||
|
second checkout at `~/dockers/officer.dev/platform` was never touched.
|
||||||
|
|
||||||
|
## What was NOT done, and why
|
||||||
|
|
||||||
|
- **`officer-dev` was skipped completely** — no pull, no `plugins:update`, no restart. This session runs
|
||||||
|
inside `officer-claude-code` on it, and its `officer` process would have been restarted out from under
|
||||||
|
the rollout. Its checkout is already at `55ecbf4f` (the tag was cut here), so **the code is present and
|
||||||
|
the running process is a build behind**. `pm2 restart officer` on `officer-dev`, when the owner is happy
|
||||||
|
with the rest, finishes this release. **Outstanding.**
|
||||||
|
|
||||||
|
- **`deadpool-rezio-bridge` was two releases behind, not one.** It came in at `bd41dff5` — the
|
||||||
|
`deploy/2026.08.21.01` commit — while every other host was at `eee73e77`. The fast-forward carried both,
|
||||||
|
14 files, and it is now level. Nothing was wrong with it; it simply had not had the interim commits.
|
||||||
|
|
||||||
|
- **`fofos` still cannot sync its capabilities.** Same `dubious ownership` refusal as `2026.08.21.01`,
|
||||||
|
same unexplained cause, still not papered over with a `safe.directory` entry. New this time and probably
|
||||||
|
the same root: its gitea plugin could not publish affordances —
|
||||||
|
`EACCES: permission denied, mkdir '/home/pastilhas/officerdev/capabilities/affordances/officerdev'`.
|
||||||
|
Both point at that capabilities checkout being owned by somebody other than `pastilhas`, which is worth
|
||||||
|
one `stat` before the next deploy. `plugins:update` exited 0 and the plugin itself updated. **Unexplained,
|
||||||
|
and now costing two things instead of one.**
|
||||||
|
|
||||||
|
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — that is the rule the last
|
||||||
|
report asked for and it worked.
|
||||||
|
|
||||||
|
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither.
|
||||||
|
|
||||||
|
## Notes for the next one
|
||||||
|
|
||||||
|
- **`macbookpro` needs the PATH helped after all — carefully.** The last report said "`$SHELL -ic` and
|
||||||
|
nothing else"; that is right for `pm2` and wrong for `bun`. `bun` is at `/opt/homebrew/bin/bun`, and
|
||||||
|
`.zshrc` does not put `/opt/homebrew/bin` on the interactive PATH, so `bun run plugins:update` failed
|
||||||
|
`command not found` before it ran a line. What works is **appending** that one directory for that one
|
||||||
|
command (`export PATH=$PATH:/opt/homebrew/bin`) and running `pm2` as a separate command. Appending
|
||||||
|
shadows nothing — `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. **Replacing** the PATH is
|
||||||
|
what broke `pm2` last time. `docs/servers.md` says "do not help the PATH" and should say "append, never
|
||||||
|
replace, and only for `bun`".
|
||||||
|
|
||||||
|
- **`$SHELL -ic` on `macbookpro` also aliases `ls` to `eza`**, which is not installed, so a probe using
|
||||||
|
`ls` reports a missing file that is plainly there. Use `command ls` or a plain non-interactive shell for
|
||||||
|
file tests.
|
||||||
|
|
||||||
|
- **`plugins:update` restarts sidecars itself.** It prints `sidecar: started` per plugin, so on `alpha`
|
||||||
|
twelve sidecars bounced during step 3 — before the plan's restart step, and not something the plan asked
|
||||||
|
for. That is the script's own behaviour and it is not new, but a report that only lists `officer` under
|
||||||
|
"restarted" is understating it. Worth a line in the plan format.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026.08.21.03 — a drill
|
||||||
|
|
||||||
|
**No code change.** This release exists to watch the watcher fire, and to exercise the claim rule that
|
||||||
|
landed after the last one.
|
||||||
|
|
||||||
|
There is nothing to bump but the version and this file, and that is deliberate rather than lazy: a tag
|
||||||
|
whose `package.json` disagrees with it makes the commander stop, and a tag with no plan beside it makes it
|
||||||
|
stop too, so the smallest honest release is still a version and a plan.
|
||||||
|
|
||||||
|
## What is being tested
|
||||||
|
|
||||||
|
- The watcher noticing a tag and spawning an agent on its own, with nobody watching the composer.
|
||||||
|
- `scripts/deploy-claim.sh` taking the claim before anything is deployed, and the deploy recording success
|
||||||
|
through the same script.
|
||||||
|
- A second pass over the same version being refused rather than repeated.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. `bun run plugins:update`
|
||||||
|
3. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. No `bun install`, no `db:push` — nothing in this range touches a schema, a dependency or
|
||||||
|
the lockfile, and `git diff deploy/2026.08.21.02..HEAD` is two files.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else restarted.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.21.03` says `done`.
|
||||||
+8
-2
@@ -11,7 +11,13 @@ deploy/
|
|||||||
The plan is written by hand before the release commit. The report is written by the deploy commander
|
The plan is written by hand before the release commit. The report is written by the deploy commander
|
||||||
afterwards and pushed back here.
|
afterwards and pushed back here.
|
||||||
|
|
||||||
**The report's presence is what marks a release deployed.** That is the whole idempotence mechanism: a
|
**A release is a tag**, `deploy/<version>`, and nothing else triggers a deploy — not a commit message,
|
||||||
watcher that restarts checks for the report before acting, so it cannot roll the same release out twice.
|
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`.
|
See `prompts/deploy-commander.md`.
|
||||||
|
|||||||
+97
-37
@@ -4,59 +4,119 @@ 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")
|
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.
|
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
|
**Verified 2026-08-21** from `officer-dev` by SSH and `pm2 jlist`, except where marked assumed.
|
||||||
`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.
|
|
||||||
|
|
||||||
## How access works
|
## How access works
|
||||||
|
|
||||||
All of it is over the tailnet. There is **no `~/.ssh/config`** — names resolve by MagicDNS and the
|
All of it is over the tailnet, key-based, no `~/.ssh/config`, no passwords, no jump hosts, no ports.
|
||||||
login is key-based, so `ssh <name>` as `pastilhas` is the whole story:
|
|
||||||
|
**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
|
```bash
|
||||||
ssh alpha
|
ssh alpha # pastilhas, implied
|
||||||
ssh fofos
|
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
|
## Officer platforms
|
||||||
|
|
||||||
| Host | Tailnet IP | Live checkout | Runs `officer` | Git remote |
|
Seven, all running.
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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 —
|
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||||
`pm2 jlist` gives the `officer` process's `pm_cwd`, which is the checkout that is actually serving.
|
reachable from inside only.
|
||||||
- **`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.
|
|
||||||
|
|
||||||
`headscale` and `omega` hold a checkout but run no `officer` process (no PM2 on either). Whether those
|
### Names that disagree with themselves
|
||||||
are retired, staging or half-installed is **not established** — ask before treating them as targets.
|
|
||||||
|
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`, APPEND to the PATH for `bun`. Never replace it.** This said "do not help the PATH"
|
||||||
|
until 2026-08-21, and that was half right in a way that cost a deploy step: `pm2` is only on the
|
||||||
|
interactive shell's PATH, so replacing it breaks `pm2` — but `bun` is at `/opt/homebrew/bin/bun` and
|
||||||
|
`.zshrc` does not put that directory on the interactive PATH either, so `bun run plugins:update` fails
|
||||||
|
`command not found` before it runs a line. `export PATH=$PATH:/opt/homebrew/bin` for that one command
|
||||||
|
shadows nothing: `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. Run `pm2` as a separate
|
||||||
|
command.
|
||||||
|
- **`$SHELL -ic` on `macbookpro` aliases `ls` to `eza`, which is not installed.** A probe using `ls`
|
||||||
|
reports a missing file that is plainly there. Use `command ls`, or a non-interactive shell for file tests.
|
||||||
|
- **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.
|
||||||
|
- **`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin — on
|
||||||
|
`alpha` that is twelve processes bouncing during the plugins step, before any restart the plan asked for.
|
||||||
|
Not a fault, but a deploy report that lists only `officer` under "restarted" is understating what moved.
|
||||||
|
- **`fofos` cannot sync its capabilities**, and could not publish gitea affordances on 2026-08-21:
|
||||||
|
`dubious ownership` from git and `EACCES … mkdir '…/capabilities/affordances/officerdev'`. Both point at
|
||||||
|
that capabilities checkout being owned by somebody other than `pastilhas`. Deliberately not papered over
|
||||||
|
with a `safe.directory` entry until somebody has run one `stat` on it. **Unexplained.**
|
||||||
|
|
||||||
|
## 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
|
## 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), `officer-bridge` (100.64.0.5).
|
||||||
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `edge-bridge` (100.64.0.15),
|
Assumed service-only containers — not confirmed. `edge-bridge` was listed here until 2026-08-21 and was
|
||||||
`gitea-bridge` (100.64.0.12), `officer-bridge` (100.64.0.5). Assumed to be service-only
|
wrong: its SSH was simply down at the time of checking.
|
||||||
containers rather than hosts to log into — not confirmed.
|
- **Client devices, not servers:** `iphone16`, `andres-ipad`, `pixel9a`, plus other people's devices in
|
||||||
- **Client devices, not servers:** `macbookpro`, `iphone16`, `andres-ipad`, `pixel9a`, plus other
|
the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`, `fedras-iphone`,
|
||||||
people's devices in the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`,
|
|
||||||
`guilhermes-macbook-pro`, `macbook-pro-de-joo`).
|
`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
|
## 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
|
It rots when a server is added, a checkout moves, a platform is retired, a remote changes or a login
|
||||||
platform retired, or a remote changed. Re-verify with the same three commands the list was built from
|
differs. Re-verify with the four commands it was built from:
|
||||||
— `tailscale status`, `pm2 jlist` for the `officer` entry's `pm_cwd`, and `git -C <cwd> remote get-url
|
|
||||||
origin`.
|
```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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.21.01",
|
"version": "2026.08.21.03",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+119
-23
@@ -15,30 +15,83 @@ 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.
|
there is a release to deploy that has not been deployed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/officer-dev/platform && git fetch --quiet origin HEAD
|
git ls-remote --tags origin 'refs/tags/deploy/*'
|
||||||
git log --oneline HEAD..FETCH_HEAD | grep DEPLOY_NOW
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Nothing matching** → say "no release pending" and stop. Do not pull, do not touch a server.
|
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
|
||||||
- **A commit whose subject begins `DEPLOY_NOW`** → that is the release. Continue.
|
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
|
**Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
|
||||||
word repeatedly and must never trigger a deploy; so must any commit that discusses one.
|
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
|
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
|
||||||
before acting. A watcher that restarts must not redeploy, and this is the only thing standing between it
|
- **A `deploy/*` tag with no local record** → that is the release. Continue.
|
||||||
and doing so.
|
|
||||||
|
|
||||||
---
|
### One attempt per release, and you do not enforce that yourself
|
||||||
|
|
||||||
|
**Before you spawn or run anything for a version, claim it:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/deploy-claim.sh claim <version> # exit 0 = it is yours, exit 1 = do not proceed
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit 1 means somebody already attempted this release. **Stop. Do not retry it, do not deploy it, and do
|
||||||
|
not decide the previous attempt does not count.** Say what the script said and move on.
|
||||||
|
|
||||||
|
On success, record it through the same script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/deploy-claim.sh done <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this is a script and not a rule you follow.** A watcher loop is written fresh each time one arms, so
|
||||||
|
a rule that lives only in this file gets re-implemented from scratch every time — and the wrong version of
|
||||||
|
it looks right: test for the `.done` file, which exists only on SUCCESS. Write that and a failing release
|
||||||
|
spawns a new agent every poll, all night, each one costing money. That is not hypothetical. It is what the
|
||||||
|
first watcher did on 2026-08-21: a careful loop, honest logging, and an unbounded retry inside it.
|
||||||
|
|
||||||
|
So the rule is code. `mkdir` is the claim — atomic, one syscall that both tests and takes, no window
|
||||||
|
between checking and acting. The script refuses a second claim and keeps refusing, whatever happened to the
|
||||||
|
first attempt.
|
||||||
|
|
||||||
|
**A failed release is therefore not retried. It is looked at.** Clearing a claim is a deliberate human act
|
||||||
|
(`rm -rf …/<version>.claim`) or a new tag. A loop cannot undo its own claim, which is the property that
|
||||||
|
makes the storm impossible rather than unlikely.
|
||||||
|
|
||||||
|
### 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 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`
|
- `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
|
||||||
increments for a second deploy on the same day.
|
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.
|
- `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
|
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.
|
at, and guessing is how a database gets a schema it was not ready for.
|
||||||
|
|
||||||
@@ -49,8 +102,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,
|
**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.
|
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
|
That document holds every machine, its tailnet name, **its login user**, its checkout and whether a
|
||||||
it at the start of every deploy, and treat the hosts marked as having no platform as out of scope.
|
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:
|
Four things that will catch you out, all verified rather than assumed:
|
||||||
|
|
||||||
@@ -71,8 +129,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
|
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.
|
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
|
1. **Refuse a dirty tree — TRACKED changes only.** `git status --porcelain --untracked-files=no`. Plain
|
||||||
so. Never discard someone's work to ship a release.
|
`--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.
|
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
|
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
|
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
|
||||||
@@ -89,13 +149,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
|
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.
|
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
|
## The report
|
||||||
|
|
||||||
Commit `deploy/<version>.report.md` to the platform repository and push it. Subject line: the version and
|
Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
|
||||||
the outcome, **without the code word** — a report is not a release, and a report that triggers a deploy
|
`deploy/<version>.report.md` and push it.
|
||||||
would loop forever.
|
|
||||||
|
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
|
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
|
processes restarted, and whether they came back. Then one line at the top saying how many hosts
|
||||||
@@ -107,7 +186,24 @@ that are hard to reconstruct afterwards.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is missing, as of writing
|
## Being woken
|
||||||
|
|
||||||
**Nothing wakes you yet.** A watcher, a cron entry or a repeating prompt has to exist and does not. Until
|
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
|
||||||
then this runs when a person starts it.
|
agent, then re-arm**. The wait happens in the shell, not in a model tick — a loop polling
|
||||||
|
`git ls-remote`, which is one network call and no tokens.
|
||||||
|
|
||||||
|
The loop cannot do a deploy: it cannot read a plan, judge a host or write a report. So on firing it spawns
|
||||||
|
a headless agent — `claude -p "<read this file and follow it>" --dangerously-skip-permissions
|
||||||
|
--output-format=stream-json --verbose` — and pipes the events through `jq` so the job panel shows progress
|
||||||
|
instead of a blank box for twenty minutes.
|
||||||
|
|
||||||
|
Two things that loop must get right, both learned by getting them wrong on 2026-08-21:
|
||||||
|
|
||||||
|
- **Claim the version first**, per the section above. This is the one that costs money when missed.
|
||||||
|
- **Set `OFFICER_ROOT` explicitly.** It is empty in the sidecar's environment, so the lock path resolves to
|
||||||
|
`/data/deploys` and the release looks permanently undeployed.
|
||||||
|
|
||||||
|
The deploy therefore runs in a **separate** agent, not in the commander's own conversation: no Command HQ
|
||||||
|
row, no cost on the commander's line, and its reasoning lives in that child's transcript. That is the price
|
||||||
|
of putting the wait in the shell, and it is worth it — a model-side tick that re-reads a whole conversation
|
||||||
|
to learn nothing is the thing being avoided.
|
||||||
|
|||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# One attempt per release, enforced by the filesystem.
|
||||||
|
#
|
||||||
|
# A watcher loop asks this before it spawns anything. Exit 0 means "you have the claim, go". Exit 1 means
|
||||||
|
# "somebody already had it" — and that answer never changes back, whatever happened to the attempt that
|
||||||
|
# held it. A release that fails is not retried; it is looked at.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A SCRIPT AND NOT A PARAGRAPH IN prompts/deploy-commander.md
|
||||||
|
#
|
||||||
|
# The watcher loop is written fresh by an agent each time it arms, so a rule that lives only in prose is
|
||||||
|
# re-implemented from scratch on every arming, and "skip a version already attempted" has a wrong version
|
||||||
|
# that looks right: test the .done file, which only exists on SUCCESS. Write that and a failing deploy
|
||||||
|
# spawns a fresh agent every poll, all night, each one costing money. That is exactly what happened on
|
||||||
|
# 2026-08-21 — the loop was careful, logged honestly, and still had an unbounded retry in it.
|
||||||
|
#
|
||||||
|
# WHY BASH AND NOT bun
|
||||||
|
#
|
||||||
|
# The thing that stops a retry storm must not depend on the checkout being healthy. A deploy that dies on
|
||||||
|
# `bun install` is precisely when this has to work.
|
||||||
|
#
|
||||||
|
# WHY mkdir
|
||||||
|
#
|
||||||
|
# It is atomic on every POSIX filesystem and it fails if the target exists — one syscall that both tests
|
||||||
|
# and claims. `[ -f x ] && ...` is two steps with a race between them, and two pollers a second apart is
|
||||||
|
# not a hypothetical when a watcher is restarted.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
DEPLOYS_DIR="${DEPLOYS_DIR:-$HOME/officer-dev/data/deploys}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'USAGE'
|
||||||
|
usage: deploy-claim.sh claim <version> take the one attempt for this release (exit 0 = yours, 1 = taken)
|
||||||
|
deploy-claim.sh done <version> record that the attempt succeeded
|
||||||
|
deploy-claim.sh state <version> print unclaimed | claimed | done
|
||||||
|
deploy-claim.sh list every release this host knows about
|
||||||
|
|
||||||
|
To retry a release deliberately: rm -rf <deploys-dir>/<version>.claim
|
||||||
|
That is a human act on purpose. A loop cannot undo its own claim.
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
version_ok() {
|
||||||
|
# The version reaches here from a git tag, so it is attacker-adjacent input used to build a path.
|
||||||
|
case "$1" in
|
||||||
|
*[!0-9.]* | '' | *..*) return 1 ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
[ -n "$CMD" ] || usage
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
list)
|
||||||
|
mkdir -p "$DEPLOYS_DIR"
|
||||||
|
found=0
|
||||||
|
for d in "$DEPLOYS_DIR"/*.claim; do
|
||||||
|
[ -e "$d" ] || continue
|
||||||
|
found=1
|
||||||
|
v=$(basename "$d" .claim)
|
||||||
|
if [ -f "$DEPLOYS_DIR/$v.done" ]; then s="done"; else s="claimed, no success recorded"; fi
|
||||||
|
printf '%-16s %s\n' "$v" "$s"
|
||||||
|
done
|
||||||
|
[ "$found" = 1 ] || echo "no releases attempted on this host"
|
||||||
|
;;
|
||||||
|
|
||||||
|
claim | done | state)
|
||||||
|
V="${2:-}"
|
||||||
|
[ -n "$V" ] || usage
|
||||||
|
version_ok "$V" || { echo "deploy-claim: refusing a version that is not digits and dots: $V" >&2; exit 2; }
|
||||||
|
mkdir -p "$DEPLOYS_DIR" || exit 2
|
||||||
|
CLAIM="$DEPLOYS_DIR/$V.claim"
|
||||||
|
DONE="$DEPLOYS_DIR/$V.done"
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
state)
|
||||||
|
if [ -f "$DONE" ]; then echo done
|
||||||
|
elif [ -d "$CLAIM" ]; then echo claimed
|
||||||
|
else echo unclaimed; fi
|
||||||
|
;;
|
||||||
|
claim)
|
||||||
|
# A pre-existing .done with no .claim is a release deployed before this script existed. Honour it.
|
||||||
|
if [ -f "$DONE" ]; then
|
||||||
|
echo "deploy-claim: $V is already deployed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if mkdir "$CLAIM" 2>/dev/null; then
|
||||||
|
printf '%s claimed on %s by pid %s\n' "$(date -Iseconds)" "$(hostname)" "$$" > "$CLAIM/by"
|
||||||
|
echo "deploy-claim: $V is yours"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'deploy-claim: %s was already attempted — %s\n' "$V" "$(cat "$CLAIM/by" 2>/dev/null || echo 'claim exists')" >&2
|
||||||
|
echo "deploy-claim: NOT retrying. Look at what failed, then rm -rf $CLAIM to allow another attempt." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
done)
|
||||||
|
[ -d "$CLAIM" ] || echo "deploy-claim: warning — recording success for $V that was never claimed" >&2
|
||||||
|
printf '%s succeeded on %s\n' "$(date -Iseconds)" "$(hostname)" >> "$DONE"
|
||||||
|
echo "deploy-claim: $V recorded as deployed"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const SCRIPT = join(import.meta.dir, 'deploy-claim.sh');
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
const run = (...args: string[]) => {
|
||||||
|
const p = Bun.spawnSync([SCRIPT, ...args], { env: { ...process.env, DEPLOYS_DIR: dir } });
|
||||||
|
return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'deploy-claim-'));
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
// The whole point of this script is that the SECOND caller is refused. Everything else is scaffolding.
|
||||||
|
describe('deploy-claim', () => {
|
||||||
|
it('grants once and refuses after, whatever the outcome was', () => {
|
||||||
|
expect(run('claim', '2026.09.01.01').code).toBe(0);
|
||||||
|
expect(run('claim', '2026.09.01.01').code).toBe(1);
|
||||||
|
// Still refused after a failure — no .done was ever written. This is the retry storm not happening.
|
||||||
|
expect(run('claim', '2026.09.01.01').code).toBe(1);
|
||||||
|
expect(run('state', '2026.09.01.01').out.trim()).toBe('claimed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a release already deployed, even with no claim beside it', () => {
|
||||||
|
// A release deployed by hand before this script existed. `deploy/2026.08.21.01` is exactly this case.
|
||||||
|
writeFileSync(join(dir, '2026.08.21.01.done'), 'deployed by hand\n');
|
||||||
|
expect(run('claim', '2026.08.21.01').code).toBe(1);
|
||||||
|
expect(run('claim', '2026.08.21.01').err).toContain('already deployed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a human hand the claim back, and only a human', () => {
|
||||||
|
expect(run('claim', '2026.09.01.02').code).toBe(0);
|
||||||
|
rmSync(join(dir, '2026.09.01.02.claim'), { recursive: true });
|
||||||
|
expect(run('claim', '2026.09.01.02').code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records success separately from the claim', () => {
|
||||||
|
run('claim', '2026.09.01.03');
|
||||||
|
run('done', '2026.09.01.03');
|
||||||
|
expect(run('state', '2026.09.01.03').out.trim()).toBe('done');
|
||||||
|
expect(existsSync(join(dir, '2026.09.01.03.done'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The version comes off a git tag, and it is concatenated into a path.
|
||||||
|
it('refuses anything that is not digits and dots', () => {
|
||||||
|
for (const bad of ['../../etc/passwd', 'x; rm -rf /', '2026.08.21.01/..', '', 'v1']) {
|
||||||
|
expect(run('claim', bad).code).toBe(2);
|
||||||
|
}
|
||||||
|
expect(existsSync(join(dir, '..', 'etc'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so plainly when nothing has been attempted', () => {
|
||||||
|
expect(run('list').out).toContain('no releases attempted');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
stopBackgroundTask,
|
stopBackgroundTask,
|
||||||
claudeSessionContext,
|
claudeSessionContext,
|
||||||
liveSessionTitle,
|
liveSessionTitle,
|
||||||
|
isCommandersDir,
|
||||||
} from './claude-sessions';
|
} from './claude-sessions';
|
||||||
import {
|
import {
|
||||||
listOpenCodeSessions,
|
listOpenCodeSessions,
|
||||||
@@ -287,7 +288,21 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
// genuinely not been written yet.
|
// genuinely not been written yet.
|
||||||
const transcriptId = session.claudeSessionId;
|
const transcriptId = session.claudeSessionId;
|
||||||
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
|
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
|
// 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.
|
// shown a live row without a name.
|
||||||
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
|
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
|
||||||
cwd: meta?.cwd || 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH, getCommandersDir } from '../../data-path';
|
||||||
import {
|
import {
|
||||||
appendTextAs,
|
appendTextAs,
|
||||||
findPathAs,
|
findPathAs,
|
||||||
@@ -108,6 +108,24 @@ export type ClaudeSessionSummary = {
|
|||||||
* one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
|
* one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
|
||||||
*/
|
*/
|
||||||
partCount?: number;
|
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. */
|
/** `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. */
|
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
||||||
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
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))
|
return mergeChains(scanGroup(who, cwd))
|
||||||
.map(publish)
|
.map(publish)
|
||||||
|
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.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.
|
* 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]);
|
}, [autoSend, isConnected, messages.length, input]);
|
||||||
|
|
||||||
return {
|
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,
|
messages,
|
||||||
historicalCount,
|
historicalCount,
|
||||||
streamingText,
|
streamingText,
|
||||||
|
|||||||
@@ -173,6 +173,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
open={jobDialogOpen}
|
open={jobDialogOpen}
|
||||||
onOpenChange={setJobDialogOpen}
|
onOpenChange={setJobDialogOpen}
|
||||||
cwd={cwd}
|
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) => {
|
onSubmit={(intent, preamble, armWatcher) => {
|
||||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
// 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.
|
// background jobs an agent starts on its own.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data';
|
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data';
|
||||||
import { useLocalStorageState } from 'hooks/useLocalStorageState';
|
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.
|
localStorage also happens to be the right scope: a phone and a desktop wanting different tabs is reasonable.
|
||||||
*/
|
*/
|
||||||
const VIEW_KEY = 'chat.sessions.view';
|
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';
|
const EXPANDED_KEY = 'chat.sessions.expanded';
|
||||||
|
|
||||||
export const SessionList = () => {
|
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
|
few seconds before the harness announces one, and a row that navigates nowhere is worse than one that
|
||||||
plainly cannot be clicked.
|
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 LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => {
|
||||||
const { live: sessions, isLoading, error, refetch } = useLiveSessions();
|
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) {
|
if (error) {
|
||||||
return (
|
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 (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 (
|
return (
|
||||||
<DataList>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{sessions.map((session) => (
|
{/* Counts shown even at zero, unlike the badge on the Live tab above. That badge answers "is anything
|
||||||
<LiveRow key={session.sessionKey} session={session} activeId={activeId} home={home} />
|
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
|
||||||
</DataList>
|
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 LiveRow = ({ session, activeId, home }: { session: LiveSession; activeId?: string; home: string | null }) => {
|
||||||
const transcriptId = session.claudeSessionId;
|
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 (
|
return (
|
||||||
<DataRow
|
<DataRow
|
||||||
to={transcriptId ? chatSessionPath(transcriptId) : undefined}
|
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
|
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
||||||
* (`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
|
* `useChat` mints the transcript id on the harness's first response (`session:claude`); once that lands the
|
||||||
* always writes `/chat/<id>` (see `writePermalink` in `useChat.ts`), which is the wrong screen here.
|
* 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 navigate = useNavigate();
|
||||||
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
|
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);
|
const [filed, setFiled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setFiled(true);
|
||||||
// `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was
|
// `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
|
// 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
|
// running its UPDATE last; done client-side here for the same reason, since the web composer goes
|
||||||
// through the websocket path instead.
|
// through the websocket path instead.
|
||||||
if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chat.sessionId, filed]);
|
}, [chat.claudeSessionId, filed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<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">
|
{chat.claudeSessionId ? (
|
||||||
New commander
|
<MetaBar
|
||||||
</div>
|
sessionId={chat.claudeSessionId}
|
||||||
<EmbeddableChat chat={chat} sessionId={undefined} className="min-h-0 flex-1" />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,6 +262,50 @@ type ResumeState = {
|
|||||||
title: string | null;
|
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 }) {
|
function ExistingCommander({ sessionId }: { sessionId: string }) {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
|
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
|
||||||
@@ -252,40 +338,11 @@ function ExistingCommander({ sessionId }: { sessionId: string }) {
|
|||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [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) {
|
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 items-center justify-center text-sm text-muted-foreground">Loading…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <ResumedCommander key={sessionId} sessionId={sessionId} resume={resume} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommandHqDetail = () => {
|
export const CommandHqDetail = () => {
|
||||||
@@ -293,7 +350,18 @@ export const CommandHqDetail = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const category = categoryFromParam(searchParams.get('category'));
|
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) 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} />;
|
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
|
// `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.
|
// reattachable the moment the harness names its transcript rather than when the turn finishes.
|
||||||
const claudeSessionIdRef = useRef<string | null>(resumeSessionId ?? null);
|
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();
|
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
|
// 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".
|
// "refresh mid-turn and watch it carry on".
|
||||||
claudeSessionIdRef.current = msg.claudeSessionId;
|
claudeSessionIdRef.current = msg.claudeSessionId;
|
||||||
|
setClaudeSessionId(msg.claudeSessionId);
|
||||||
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -391,6 +405,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
// (`msg.sessionId || randomUUID()`) that `/chat/sessions/:id` cannot resolve.
|
// (`msg.sessionId || randomUUID()`) that `/chat/sessions/:id` cannot resolve.
|
||||||
if (msg.claudeSessionId) {
|
if (msg.claudeSessionId) {
|
||||||
claudeSessionIdRef.current = msg.claudeSessionId;
|
claudeSessionIdRef.current = msg.claudeSessionId;
|
||||||
|
setClaudeSessionId(msg.claudeSessionId);
|
||||||
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
||||||
}
|
}
|
||||||
commitStreaming();
|
commitStreaming();
|
||||||
@@ -830,6 +845,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
isConnected,
|
isConnected,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
/** Claude's own transcript id — what a commander is filed under, and what a permalink must use. */
|
||||||
|
claudeSessionId,
|
||||||
model,
|
model,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
hasStarted,
|
hasStarted,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export type LiveSession = {
|
|||||||
*/
|
*/
|
||||||
title: string | null;
|
title: string | null;
|
||||||
cwd: 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';
|
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';
|
||||||
|
|||||||
Reference in New Issue
Block a user