Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98177c3bc9 | ||
|
|
3cab16ca5b | ||
|
|
f023ab5da0 | ||
|
|
b10a49c082 | ||
|
|
4b2cb3d266 | ||
|
|
7d7a80782e | ||
|
|
bd9d26199f | ||
|
|
58615ee5e7 | ||
|
|
31cdb93691 | ||
|
|
8917f36be5 | ||
|
|
530e5880e1 | ||
|
|
887749722c | ||
|
|
9ce81547c4 | ||
|
|
3343b21a17 | ||
|
|
aac8af5e1f | ||
|
|
8be09a012b | ||
|
|
55ecbf4fb8 | ||
|
|
926ce86b83 | ||
|
|
e1ad680475 | ||
|
|
ebfa09e067 | ||
|
|
6dae3e7a14 | ||
|
|
2379260ffe | ||
|
|
21b893473d | ||
|
|
af78f0ed51 | ||
|
|
7a46612f41 | ||
|
|
240d8adbd3 | ||
|
|
8662f94b3b |
@@ -20,38 +20,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**78 open, 20 closed**, across 28 themes.
|
||||
**84 open, 22 closed**, across 28 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 1 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 3 | 0 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 2 | 1 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 2 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 3 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 4 | 1 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 3 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
|
||||
## Records — no items, kept for the reasoning
|
||||
|
||||
@@ -65,10 +65,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
||||
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
||||
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
|
||||
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
|
||||
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
|
||||
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
|
||||
- `2026-08-21` **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
||||
- `2026-08-21` **A way to mark a commit "this one ships", so a release is not just `master`'s tip** — [fleet/a-way-to-mark-a-commit-this-one-ships-so-a](todo/fleet/a-way-to-mark-a-commit-this-one-ships-so-a.md)
|
||||
- `2026-08-21` **OPM — Officer's own process manager, or at least its own PM2** — [infra/opm-officer-s-own-process-manager](todo/infra/opm-officer-s-own-process-manager.md)
|
||||
- `2026-08-21` **iPhone Action Button → run an Officer task from a locked phone** — [mobile-apps/iphone-action-button-run-an-officer-task-from](todo/mobile-apps/iphone-action-button-run-an-officer-task-from.md)
|
||||
- `2026-08-21` **'A deploy verifies PM2, not that the app still works'** — [fleet/a-deploy-report-verifies-pm2-not-that-the-app](todo/fleet/a-deploy-report-verifies-pm2-not-that-the-app.md)
|
||||
- `2026-08-21` **'The deploy commander cannot finish its own host'** — [fleet/the-deploy-commander-cannot-finish-its-own-host](todo/fleet/the-deploy-commander-cannot-finish-its-own-host.md)
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 2026.08.21.03 — deployed
|
||||
|
||||
**6/6 hosts.** Every platform the plan reaches is on `3343b21a`, `officer` restarted and back `online`,
|
||||
and no other process on any host was left in a state other than `online`.
|
||||
|
||||
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this deploy ran on.
|
||||
See below; its skip is now two releases deep.
|
||||
|
||||
This was a drill. The release changes `package.json` and adds its own plan file, nothing else, and it
|
||||
exists to watch the claim rule work. It did: `scripts/deploy-claim.sh claim 2026.08.21.03` was taken
|
||||
before any host was touched, and `state` reads `done`.
|
||||
|
||||
## The release
|
||||
|
||||
Tag `deploy/2026.08.21.03` → `3343b21a`; `package.json` at that commit says `2026.08.21.03`. They agree.
|
||||
Plan: `deploy/2026.08.21.03.md` — pull, `plugins:update`, restart `officer` only. No `bun install`, no
|
||||
`db:push`, and neither ran anywhere.
|
||||
|
||||
| Host | Before | After | plugins | restarted | restart_time | back |
|
||||
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
|
||||
| `alpha` | `55ecbf4f` | `3343b21a` | 12/12 | officer | 61 → 62 | online |
|
||||
| `fofos` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 16 → 17 | online |
|
||||
| `macbookpro` | `55ecbf4f` | `3343b21a` | none installed | officer | 3 → 4 | online |
|
||||
| `edge-pertento-bridge` | `55ecbf4f` | `3343b21a` | 2/2 | officer | 5 → 6 | online |
|
||||
| `production-pertento-bridge` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 7 → 8 | online |
|
||||
| `deadpool-rezio-bridge` | `55ecbf4f` | `3343b21a` | none installed | officer | 1 → 2 | online |
|
||||
| `officer-dev` | `3343b21a` | `3343b21a` | not run | **none** | 155 | skipped |
|
||||
|
||||
Every host came in at `55ecbf4f` — the previous release, level for the first time in three deploys — and
|
||||
every pull was a real fast-forward. Checkout paths were taken from each `officer` process's `pm_cwd`, so
|
||||
`alpha`'s stale second checkout at `~/dockers/officer.dev/platform` was never touched. `macbookpro` was
|
||||
awake.
|
||||
|
||||
**More than `officer` restarted, and the plan did not ask for it.** `bun run plugins:update` bounces each
|
||||
plugin's sidecar as it goes: twelve on `alpha`, two on `edge`, one each on `fofos` and
|
||||
`production-pertento-bridge`. Every one came back `online`. Nothing was left down.
|
||||
|
||||
## What was NOT done, and why
|
||||
|
||||
- **`officer-dev` was skipped completely** — no pull (it is already at the tag, which was cut here), no
|
||||
`plugins:update`, no restart. This session runs inside `officer-claude-code` on it, and restarting
|
||||
`officer` there ends the rollout mid-flight.
|
||||
|
||||
**Its running process is now two releases old.** `restart_time` is 155, unchanged since before
|
||||
`2026.08.21.02` — so the same restart the last report left outstanding is still outstanding, and it now
|
||||
carries both releases. The code for both is on disk. `pm2 restart officer` on `officer-dev` finishes
|
||||
them, whenever the owner wants it. **Outstanding, and no longer new.**
|
||||
|
||||
- **`fofos` still cannot sync its capabilities.** Third deploy running, same two failures, same
|
||||
unexplained cause:
|
||||
|
||||
- `capabilities: failed — fatal: detected dubious ownership in repository at
|
||||
'/home/pastilhas/officerdev/capabilities'`
|
||||
- `affordances: NOT published — EACCES: permission denied, mkdir
|
||||
'/home/pastilhas/officerdev/capabilities/affordances/officerdev'`
|
||||
|
||||
Both say that checkout is not owned by `pastilhas`. Still not papered over with a `safe.directory`
|
||||
entry, because a `safe.directory` line would silence the first message and leave the `EACCES` — they are
|
||||
one cause, and one `stat` settles it. `plugins:update` exited 0 and the gitea plugin itself updated, so
|
||||
this costs capability sync on that host and nothing else. **Unexplained.**
|
||||
|
||||
- **`fofos` still carries one untracked file**, `scripts/chk.ts`. A note, not a skip — the rule works.
|
||||
|
||||
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither, and
|
||||
`deploy/2026.08.21.02..deploy/2026.08.21.03` is two files.
|
||||
|
||||
- **Nothing was verified beyond `pm2`.** `officer` is `online` with a moved `restart_time` on all six
|
||||
hosts; no HTTP request was made to any domain. That is what the plan asked to check.
|
||||
|
||||
## Notes for the next one
|
||||
|
||||
- **The `macbookpro` PATH rule in `docs/servers.md` is now correct and was followed as written.**
|
||||
`export PATH=$PATH:/opt/homebrew/bin` for the `bun` command, `pm2` as a separate command, and both
|
||||
worked first time. Nothing to change.
|
||||
|
||||
- **`$SHELL -ic "pm2 jlist"` with stderr discarded came back empty once**, on `edge-pertento-bridge`, and
|
||||
the same command with `2>&1` a second later returned the full list. Transient, and it looked exactly
|
||||
like a dead pm2. Capture stderr and parse the JSON out of the stream rather than trusting the whole of
|
||||
stdout to be JSON — an interactive shell puts its own noise (`starship`, aliases) in there.
|
||||
|
||||
- **`officer-dev`'s skip needs a home other than a report.** It has been carried forward twice now, and a
|
||||
report is not a place work waits — the next deploy will write the same paragraph. Either the owner
|
||||
restarts it, or it belongs in `TODO.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 2026.08.21.04
|
||||
|
||||
`/login` in the chat window, switching between Anthropic accounts from the usage pills, and a Spotlight
|
||||
that can actually reach the plugins.
|
||||
|
||||
**This release has a SCHEMA CHANGE.** The previous two did not, and said so; this one adds
|
||||
`anthropic_accounts`. Without the push, `GET /api/claude-auth/accounts` fails on a missing relation the
|
||||
first time the usage panel is opened — so `bun db:push` is a step here, not an optional tidy.
|
||||
|
||||
No dependency change: nothing in `deploy/2026.08.21.03..HEAD` touches `package.json` dependencies or
|
||||
`bun.lock`. Do not run `bun install`.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`/login` works in the chat composer.** `claude auth login` behaves the same through plain pipes as
|
||||
through a terminal, so it is a subprocess with two reads and one write — no pty. Copy-link beside the
|
||||
open-link button, because opening it here opens it in the browser already signed into the account you are
|
||||
leaving.
|
||||
- **Account switching from the usage pills.** `anthropic_accounts` keeps each login's OAuth credential
|
||||
encrypted under a per-account key from the secret store, so a switch needs no browser round trip. An
|
||||
account with no saved credential still gets a chip you can read — cached percentages and a correct reset
|
||||
time — marked as needing `/login`.
|
||||
- **Spotlight reached eight items.** Core filled all eight slots, so on a populated server every plugin was
|
||||
unreachable. The cap is gone, rows draw the dock's own tiles, and the settings list went from nine
|
||||
hand-written rows to twenty read off the screens that declare them.
|
||||
- The ephemeral pane opens from the markup rather than an effect.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git pull --ff-only`
|
||||
2. **`bun db:push`** — new this release, see above
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only; no sidecar changed. Restarting it rebuilds the SPA, which is what ships the frontend.
|
||||
|
||||
**`alpha` is already at this code**, deployed by hand as a preview before the tag. Its pull will be a
|
||||
no-op and that is expected — run the remaining steps anyway rather than skipping the host, so its
|
||||
`restart_time` moves with everyone else's.
|
||||
|
||||
**On `macbookpro`, append to the PATH for `bun`** — `export PATH=$PATH:/opt/homebrew/bin` — and run `pm2`
|
||||
as a separate command. `docs/servers.md` has the detail; replacing the PATH is what broke `pm2` on an
|
||||
earlier deploy.
|
||||
|
||||
## Then check
|
||||
|
||||
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing else restarted by step 4 — note that
|
||||
step 3 restarts every plugin's sidecar on its own, which is the script's behaviour and worth recording
|
||||
rather than omitting.
|
||||
|
||||
`scripts/deploy-claim.sh state 2026.08.21.04` 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
|
||||
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`.
|
||||
|
||||
+109
-37
@@ -4,59 +4,131 @@ 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`, 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` failed its capabilities sync for three deploys, and it was ownership.** Fixed 2026-08-21.
|
||||
Two errors that read as unrelated problems — `dubious ownership` from git, and
|
||||
`EACCES … mkdir '…/capabilities/affordances/officerdev'` from the gitea plugin — were one cause in two
|
||||
dialects: fifty paths under that checkout were owned by `root`, including `.git` and `affordances`, all
|
||||
stamped `19 Aug 21:43`. Something was run with `sudo` and left them behind. Mode `755` is why the split
|
||||
looked odd: everything could be READ, so git saw the repo and refused it on ownership while the plugin
|
||||
saw `affordances` and could not write into it.
|
||||
|
||||
One `stat` settled it, and that is the general lesson — `stat` prints the owner rather than inferring
|
||||
anything, so it either proves ownership is the cause or eliminates it, in one command. Fixed with
|
||||
`sudo chown -R pastilhas:pastilhas ~/officerdev/capabilities`; verified after by a `mkdir` into the
|
||||
directory that used to refuse one.
|
||||
|
||||
**A `safe.directory` entry was deliberately NOT used**, and that call is worth keeping. It would have
|
||||
silenced the git message and left the `EACCES` untouched — one symptom of a two-symptom cause hidden,
|
||||
with the obvious explanation spent.
|
||||
|
||||
## 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.21.01",
|
||||
"version": "2026.08.21.04",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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.
|
||||
|
||||
```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.
|
||||
|
||||
---
|
||||
### 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 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 +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,
|
||||
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 +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
|
||||
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 +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
|
||||
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
|
||||
@@ -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
|
||||
then this runs when a person starts it.
|
||||
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,18 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
//
|
||||
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
/*
|
||||
No cap, deliberately, and it used to be 8.
|
||||
|
||||
Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock
|
||||
carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the
|
||||
list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to
|
||||
type it. A launcher you can only use for things you can already name is not doing the job.
|
||||
|
||||
The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a
|
||||
layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a
|
||||
cap becomes indistinguishable from a missing feature.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Is the user typing into something?
|
||||
@@ -71,8 +82,15 @@ const Row = ({
|
||||
onPick: () => void;
|
||||
}) => {
|
||||
const Icon = item.icon;
|
||||
// Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the
|
||||
// row is actually out of sight, so arrowing through the visible middle of the list does not jump it.
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [active]);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
|
||||
// overlay on the blur handler before the navigation ever ran.
|
||||
@@ -84,16 +102,37 @@ const Row = ({
|
||||
active ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{/*
|
||||
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
|
||||
these are the SAME things and a second visual language for them is what made this list read as
|
||||
somebody else's menu:
|
||||
|
||||
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
|
||||
invisible, which is why the fill is not decoration
|
||||
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
|
||||
mistake, and every plugin shipping its own art is this case
|
||||
|
||||
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
|
||||
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
||||
*/}
|
||||
<span
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
style={item.color ? { backgroundColor: `${item.color}22`, color: item.color } : undefined}
|
||||
style={{
|
||||
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
||||
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
||||
color: item.color ? '#fff' : undefined,
|
||||
}}
|
||||
>
|
||||
{item.image ? (
|
||||
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
|
||||
<img
|
||||
src={item.image}
|
||||
alt=""
|
||||
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className="h-4 w-4" />
|
||||
<Icon className={`h-4 w-4 ${item.color ? '' : 'text-muted-foreground'}`} />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -115,7 +154,7 @@ export const Spotlight = () => {
|
||||
const navigate = useNavigate();
|
||||
const items = useSpotlightItems();
|
||||
|
||||
const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [items, query]);
|
||||
const results = useMemo(() => rank(items, query), [items, query]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Settings, User, Puzzle, Server, Cpu, Users } from 'lucide-react';
|
||||
import {
|
||||
Bot,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
Mail,
|
||||
Mic,
|
||||
ScanText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
User,
|
||||
Users,
|
||||
Volume2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
|
||||
|
||||
@@ -32,6 +49,15 @@ export type SpotlightItem = {
|
||||
/** Plugin artwork, when the tile has some. */
|
||||
image?: string;
|
||||
color?: string;
|
||||
/**
|
||||
* Copied from the dock tile, and it has to be.
|
||||
*
|
||||
* `bare` means the artwork brings its own frame — calendar, contacts, and every plugin shipping a logo.
|
||||
* Dropping it here painted a coloured swatch behind those logos, which the dock's own comment calls out
|
||||
* as reading like a mistake. It was dropped, so Spotlight looked like a different app's list of the same
|
||||
* things.
|
||||
*/
|
||||
tile?: 'badge' | 'bare';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,19 +67,67 @@ export type SpotlightItem = {
|
||||
* will not appear here until someone adds it. The alternative is exporting every settings screen's
|
||||
* section array up into the shell, which couples five screens to this one to save five lines.
|
||||
*
|
||||
* Worth revisiting if it drifts. `hint` is what makes them findable — typing "keys" should reach the API
|
||||
* keys screen, and the label alone does not say where it lives.
|
||||
* IT DRIFTED, and within two days. The first version listed nine rows against a settings area that has
|
||||
* twenty-one, missed Languages and Voice entirely, pointed `/settings/users` at a route that does not
|
||||
* exist (it is `/settings/user-management/users`), and gave four different rows the same User glyph —
|
||||
* which is most of why the whole panel read as a generic list rather than as the app's own.
|
||||
*
|
||||
* So the cost is now measured rather than predicted, and deriving these from the screens is the fix if it
|
||||
* happens a third time. Each group below names the file it is copied from, so checking is one grep.
|
||||
*
|
||||
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
|
||||
* does not say where it lives.
|
||||
*/
|
||||
const SETTINGS_ITEMS: SpotlightItem[] = [
|
||||
// Profile — ProfileSettings/index.tsx
|
||||
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
|
||||
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: User },
|
||||
{ id: 'set:api-keys', label: 'API keys', to: '/settings/profile/api-keys', hint: 'per-app keys', icon: User },
|
||||
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: User },
|
||||
{ id: 'set:integrations', label: 'Integrations', to: '/settings/integrations', icon: Puzzle },
|
||||
{ id: 'set:server', label: 'Server settings', to: '/settings/server', icon: Server },
|
||||
{ id: 'set:system', label: 'System', to: '/settings/system', icon: Cpu },
|
||||
{ id: 'set:users', label: 'Users', to: '/settings/users', hint: 'accounts and permissions', icon: Users },
|
||||
{ id: 'set:ai', label: 'AI', to: '/settings/ai', hint: 'models and harnesses', icon: Settings },
|
||||
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
|
||||
{
|
||||
id: 'set:api-keys',
|
||||
label: 'API keys',
|
||||
to: '/settings/profile/api-keys',
|
||||
hint: 'keys that sign in as you',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
id: 'set:languages',
|
||||
label: 'Languages',
|
||||
to: '/settings/profile/languages',
|
||||
hint: 'spoken and translation',
|
||||
icon: Globe,
|
||||
},
|
||||
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
|
||||
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
|
||||
// AI — AISettings.tsx
|
||||
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
|
||||
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
|
||||
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
|
||||
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
|
||||
// System — SystemSettings.tsx
|
||||
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
|
||||
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
|
||||
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
|
||||
{
|
||||
id: 'set:maintenance',
|
||||
label: 'Rebuild & restart',
|
||||
to: '/settings/system/maintenance',
|
||||
hint: 'maintenance',
|
||||
icon: Cpu,
|
||||
},
|
||||
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
|
||||
// Integrations — IntegrationsSettings
|
||||
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
|
||||
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:dav',
|
||||
label: 'Calendar & Contacts sync',
|
||||
to: '/settings/integrations/dav-app-passwords',
|
||||
hint: 'app passwords',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
// Users — UserManagement
|
||||
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
|
||||
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
|
||||
];
|
||||
|
||||
/** Everything Spotlight can offer this account, in a stable order. */
|
||||
@@ -70,6 +144,7 @@ export function useSpotlightItems(): SpotlightItem[] {
|
||||
icon: item.icon,
|
||||
image: item.image,
|
||||
color: item.color,
|
||||
tile: item.tile,
|
||||
}));
|
||||
|
||||
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,147 @@
|
||||
import { and, desc, eq, lt } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { decryptSecret, encryptSecret } from '../crypto';
|
||||
import { anthropicAccounts } from './schema';
|
||||
|
||||
/*
|
||||
A stored Anthropic login, and the two shapes it comes in.
|
||||
|
||||
`StoredAccount` is what an API may return: who it is, how stale, whether it can still be used. It carries
|
||||
no secret. `revealAccount` is the only way to the credential itself and exists for exactly one caller —
|
||||
the switch, which writes it back to disk.
|
||||
*/
|
||||
|
||||
/** The encryption purpose for one account. Per-account by design; see the schema note. */
|
||||
const purposeFor = (accountUuid: string) => `anthropic-account:${accountUuid}`;
|
||||
|
||||
export type StoredAccount = {
|
||||
accountUuid: string;
|
||||
emailAccount: string;
|
||||
orgName: string | null;
|
||||
plan: string | null;
|
||||
capturedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
refreshExpiresAt: string | null;
|
||||
/** False once the refresh token is past its expiry: switching would fail, so the UI must offer /login. */
|
||||
usable: boolean;
|
||||
};
|
||||
|
||||
type SaveParams = {
|
||||
userId: number;
|
||||
accountUuid: string;
|
||||
emailAccount: string;
|
||||
orgName: string | null;
|
||||
plan: string | null;
|
||||
/** The `claudeAiOauth` object, verbatim, as JSON. */
|
||||
credentialsJson: string;
|
||||
/** The `oauthAccount` object, verbatim, as JSON. */
|
||||
oauthAccountJson: string;
|
||||
refreshExpiresAt: Date | null;
|
||||
};
|
||||
|
||||
const publish = (row: typeof anthropicAccounts.$inferSelect): StoredAccount => ({
|
||||
accountUuid: row.accountUuid,
|
||||
emailAccount: row.emailAccount,
|
||||
orgName: row.orgName,
|
||||
plan: row.plan,
|
||||
capturedAt: row.capturedAt.toISOString(),
|
||||
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||
refreshExpiresAt: row.refreshExpiresAt?.toISOString() ?? null,
|
||||
usable: !row.refreshExpiresAt || row.refreshExpiresAt.getTime() > Date.now(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Record (or re-record) one account's credential.
|
||||
*
|
||||
* Upsert on `(userId, accountUuid)` rather than insert, because capturing happens every time the current
|
||||
* account is noticed to have changed — so the common case is refreshing a row that already exists with a
|
||||
* newer token. `capturedAt` moves; `lastUsedAt` deliberately does not, since capturing is not using.
|
||||
*/
|
||||
export async function saveAnthropicAccount(p: SaveParams): Promise<void> {
|
||||
const purpose = purposeFor(p.accountUuid);
|
||||
const values = {
|
||||
userId: p.userId,
|
||||
accountUuid: p.accountUuid,
|
||||
emailAccount: p.emailAccount,
|
||||
orgName: p.orgName,
|
||||
plan: p.plan,
|
||||
credentials: encryptSecret(purpose, p.credentialsJson),
|
||||
oauthAccount: encryptSecret(purpose, p.oauthAccountJson),
|
||||
refreshExpiresAt: p.refreshExpiresAt,
|
||||
capturedAt: new Date(),
|
||||
};
|
||||
await db
|
||||
.insert(anthropicAccounts)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [anthropicAccounts.userId, anthropicAccounts.accountUuid],
|
||||
set: {
|
||||
emailAccount: values.emailAccount,
|
||||
orgName: values.orgName,
|
||||
plan: values.plan,
|
||||
credentials: values.credentials,
|
||||
oauthAccount: values.oauthAccount,
|
||||
refreshExpiresAt: values.refreshExpiresAt,
|
||||
capturedAt: values.capturedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Every account this Officer user can switch to, most recently used first. Carries no secret. */
|
||||
export async function listAnthropicAccounts(userId: number): Promise<StoredAccount[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(anthropicAccounts)
|
||||
.where(eq(anthropicAccounts.userId, userId))
|
||||
.orderBy(desc(anthropicAccounts.lastUsedAt), desc(anthropicAccounts.capturedAt));
|
||||
return rows.map(publish);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential itself, for the switch. Decrypts, so every call is a secret leaving the database.
|
||||
*
|
||||
* Returns null for an unknown account AND for one whose refresh token has expired: a caller that wrote an
|
||||
* expired credential to disk would have signed the owner out with no message, which is the worst of the
|
||||
* available failures.
|
||||
*/
|
||||
export async function revealAnthropicAccount(
|
||||
userId: number,
|
||||
accountUuid: string,
|
||||
): Promise<{ credentialsJson: string; oauthAccountJson: string; emailAccount: string } | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(anthropicAccounts)
|
||||
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
if (row.refreshExpiresAt && row.refreshExpiresAt.getTime() <= Date.now()) return null;
|
||||
const purpose = purposeFor(row.accountUuid);
|
||||
return {
|
||||
credentialsJson: decryptSecret(purpose, row.credentials),
|
||||
oauthAccountJson: decryptSecret(purpose, row.oauthAccount),
|
||||
emailAccount: row.emailAccount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks the switch. Separate from reveal so a failed write does not claim the account is in use. */
|
||||
export async function markAnthropicAccountUsed(userId: number, accountUuid: string): Promise<void> {
|
||||
await db
|
||||
.update(anthropicAccounts)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop credentials that can no longer be used.
|
||||
*
|
||||
* A dead refresh token is not a credential, it is a liability with a thirty-day head start already spent —
|
||||
* so it goes rather than sitting in the table for ever. Called opportunistically on list, not scheduled: a
|
||||
* cron for one DELETE is a moving part that can silently stop.
|
||||
*/
|
||||
export async function purgeExpiredAnthropicAccounts(userId: number): Promise<number> {
|
||||
const gone = await db
|
||||
.delete(anthropicAccounts)
|
||||
.where(and(eq(anthropicAccounts.userId, userId), lt(anthropicAccounts.refreshExpiresAt, new Date())))
|
||||
.returning({ id: anthropicAccounts.id });
|
||||
return gone.length;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* The OAuth credential for each Anthropic account the owner signs into, so they can be switched between
|
||||
* without a browser round trip.
|
||||
*
|
||||
* ── Why keep them at all ──
|
||||
*
|
||||
* The owner has a personal account and a company one and moves between them during a day — by what they are
|
||||
* working on, and sometimes because one has hit a limit and the work has to continue. `claude auth login`
|
||||
* handles the first switch and every switch after it, at the cost of a browser round trip each time, and it
|
||||
* cannot switch BACK to an account whose credential the CLI has already overwritten.
|
||||
*
|
||||
* `ai_provider_usage` already answers "which of my accounts has room". This answers the question that
|
||||
* immediately follows it: "then use that one".
|
||||
*
|
||||
* ── One key per account, not one key for the table ──
|
||||
*
|
||||
* `credentials` and `oauthAccount` are encrypted with `encryptSecret('anthropic-account:<uuid>', …)`, so the
|
||||
* secret store mints a separate key per Anthropic account. One leaked key is one account rather than all of
|
||||
* them, which is the same reasoning that split `VAULT_STORE_KEY` into purposes in the first place. The uuid
|
||||
* comes from Anthropic (`oauthAccount.accountUuid`) so it is stable across a re-login, where the email is
|
||||
* also stable but is user-editable at the provider.
|
||||
*
|
||||
* ── What is stored, and what that is worth to somebody who steals it ──
|
||||
*
|
||||
* The whole `claudeAiOauth` object, which includes a REFRESH token — deliberately, because the access token
|
||||
* lives hours and the refresh token is what makes a switch work a week later. So a row plus the key is
|
||||
* thirty days of acting as that account. That is a real escalation over holding one credential on disk, it
|
||||
* was accepted knowingly, and two things reduce it: the per-account key above, and `refreshExpiresAt` being
|
||||
* a column so a dead credential can be purged rather than sitting here for ever.
|
||||
*
|
||||
* ── Owner only, and per Officer user regardless ──
|
||||
*
|
||||
* Only the owner's `~/.claude` is switched today; a member's `claude` reads their own home and is untouched.
|
||||
* The rows are keyed by Officer user anyway, because the same mechanism per home is the obvious extension
|
||||
* and a table that assumed one user would have to be migrated to get there.
|
||||
*/
|
||||
export const anthropicAccounts = pgTable(
|
||||
'anthropic_accounts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Anthropic's own id for the account. The encryption purpose is derived from this. */
|
||||
accountUuid: text('account_uuid').notNull(),
|
||||
/** What the pill reads, and what `ai_provider_usage.email_account` joins on in practice. */
|
||||
emailAccount: text('email_account').notNull(),
|
||||
/** `Pertento`, or null on a personal account with no organisation. Display only. */
|
||||
orgName: text('org_name'),
|
||||
/** `Team`, `Max 20x`, `Pro`. Display only. */
|
||||
plan: text('plan'),
|
||||
/** `claudeAiOauth` from `.credentials.json`, encrypted. Never logged, never returned by an API. */
|
||||
credentials: text('credentials').notNull(),
|
||||
/** `oauthAccount` from `.claude.json`, encrypted. Identity rather than credential, but it travels with it. */
|
||||
oauthAccount: text('oauth_account').notNull(),
|
||||
/**
|
||||
* When the refresh token dies. Past this, a switch cannot succeed and the UI must say so instead of
|
||||
* failing at spawn time — which would look like the agent being broken rather than a sign-in expiring.
|
||||
*/
|
||||
refreshExpiresAt: timestamp('refresh_expires_at', { withTimezone: true }),
|
||||
/** When this credential was captured — how stale the thing you are switching to is. */
|
||||
capturedAt: timestamp('captured_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** Last time it was switched TO. Orders the pills by what the owner actually uses. */
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit mis-diffs named composite unique CONSTRAINTS and
|
||||
// re-creates them on every push. See src/databases/CLAUDE.md → "Composite keys".
|
||||
(table) => [uniqueIndex('uq_anthropic_accounts_account').on(table.userId, table.accountUuid)],
|
||||
);
|
||||
@@ -37,6 +37,7 @@ export * from './chat-session-models';
|
||||
export * from './chat-notification-prefs';
|
||||
export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from './chat-events/schema'; // chat_session_events
|
||||
export * from './chat-session-models/schema'; // chat_session_models
|
||||
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
|
||||
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
|
||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
listAnthropicAccounts,
|
||||
markAnthropicAccountUsed,
|
||||
purgeExpiredAnthropicAccounts,
|
||||
revealAnthropicAccount,
|
||||
saveAnthropicAccount,
|
||||
} from 'officerdb';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getHomeDir, getOwnerHomeDir } from '../../data-path';
|
||||
import { claudeBinIn } from '../../os-user-claude';
|
||||
import { runAsArgv } from '../../os-user';
|
||||
|
||||
/*
|
||||
`/login` in the chat composer, doing what it does in a terminal.
|
||||
|
||||
Officer's members and its owner both authenticate `claude` with a Claude SUBSCRIPTION over OAuth, not with
|
||||
an Anthropic API key — see `sidecar/claude/state.ts`. That flow is a browser round trip: the CLI prints a
|
||||
URL, you authorise in claude.com, and you paste a code back. It is the one part of running an agent that
|
||||
a browser-only user could not do from the browser: they had to open a terminal panel and drive the CLI by
|
||||
hand, which works and is exactly the thing this removes.
|
||||
|
||||
It needs no pty. `claude auth login` behaves identically through plain pipes — verified 2026-08-21 by
|
||||
capturing its raw bytes — so this is an ordinary subprocess with two reads and one write:
|
||||
|
||||
spawn ─► read stdout until the URL ─► [person authorises] ─► write the code to stdin ─► exit
|
||||
|
||||
The one non-obvious piece is the parsing. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
|
||||
again as visible text, so a naive `https://\S+` match returns two URLs concatenated with no separator.
|
||||
Stripping the escape wrappers first leaves exactly one.
|
||||
*/
|
||||
|
||||
/** OSC-8 hyperlink: `ESC ] 8 ; ; <uri> BEL` … `ESC ] 8 ; ; BEL`. Both halves go. */
|
||||
const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
||||
const SGR = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
const visibleText = (raw: string): string => raw.replace(OSC8, '').replace(SGR, '');
|
||||
|
||||
/** The URL the person has to open, or null while the CLI has not printed it yet. */
|
||||
export function extractLoginUrl(raw: string): string | null {
|
||||
const m = visibleText(raw).match(/https:\/\/\S+/);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
/** The CLI is waiting on stdin. Distinct from "printed a URL" — it prints the URL first. */
|
||||
export const awaitingCode = (raw: string): boolean => /Paste code here/i.test(visibleText(raw));
|
||||
|
||||
type Pending = {
|
||||
userId: number;
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
url: string;
|
||||
out: string;
|
||||
reaper: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
/*
|
||||
In memory, and deliberately not in Postgres.
|
||||
|
||||
A pending login is worth nothing after the process holding its PKCE verifier dies, so persisting it would
|
||||
store a row that can only ever be stale. A restart losing them is correct: the code in the person's
|
||||
clipboard is already useless, and the honest recovery is to type `/login` again.
|
||||
*/
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
/*
|
||||
The authorisation code expires and so does the person's patience. Ten minutes is long enough to find a
|
||||
password manager and short enough that a forgotten tab does not leave a `claude` process parked on a
|
||||
read of stdin for the rest of the week.
|
||||
*/
|
||||
const TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function abandon(id: string): void {
|
||||
const p = pending.get(id);
|
||||
if (!p) return;
|
||||
clearTimeout(p.reaper);
|
||||
pending.delete(id);
|
||||
try {
|
||||
p.child.kill('SIGTERM');
|
||||
} catch {
|
||||
// Already gone. Nothing to do and nothing worth logging.
|
||||
}
|
||||
}
|
||||
|
||||
type Who = { id: number; isOwner: boolean; osUser: string | null; email: string };
|
||||
|
||||
/**
|
||||
* The login process for one account.
|
||||
*
|
||||
* The owner runs it as themselves, in their real home, so it refreshes the very credentials
|
||||
* `officer-anthropic-proxy` reads. A member runs through `runAsArgv` — `setpriv --reset-env` — as their own
|
||||
* Linux user, writing into their own home, which is the whole point: their turns bill their subscription
|
||||
* rather than the owner's, and that only holds if the credential is theirs.
|
||||
*
|
||||
* Nothing Anthropic-shaped is inherited in either case. For the owner those variables point at the local
|
||||
* proxy, and the proxy exists to swap a shared secret for the owner's bearer token — handing them to a
|
||||
* login is how you get a "success" that authenticates nothing.
|
||||
*/
|
||||
function spawnLogin(who: Who): ChildProcessWithoutNullStreams {
|
||||
const stripped = { ...process.env };
|
||||
for (const name of [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_FOUNDRY_API_KEY',
|
||||
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
])
|
||||
delete stripped[name];
|
||||
|
||||
if (who.isOwner) {
|
||||
return spawn('claude', ['auth', 'login', '--claudeai'], {
|
||||
env: stripped,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
if (!who.osUser) throw new Error('no Linux account: an agent for this member cannot hold its own credentials');
|
||||
|
||||
const home = getHomeDir(who.email);
|
||||
// `env` inside the argv, because `--reset-env` discards anything handed to `setpriv` itself. Only PATH,
|
||||
// and only so `claude` can find `node`; nothing here carries a credential.
|
||||
const [launcher, ...args] = runAsArgv(who.osUser, [claudeBinIn(home), 'auth', 'login', '--claudeai']);
|
||||
if (!launcher) throw new Error('runAsArgv returned an empty argv');
|
||||
return spawn(launcher, args, { cwd: home, stdio: ['pipe', 'pipe', 'pipe'] }) as ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
/** Reads until the URL appears, or the process dies, or we run out of patience waiting for it. */
|
||||
function urlFrom(child: ChildProcessWithoutNullStreams): Promise<{ url: string; out: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let out = '';
|
||||
// Separate from TTL_MS: this is the CLI failing to start, which shows up as silence rather than an error.
|
||||
const giveUp = setTimeout(
|
||||
() => reject(new Error(`no login URL after 25s. Output so far: ${visibleText(out)}`)),
|
||||
25_000,
|
||||
);
|
||||
const settle = (fn: () => void) => {
|
||||
clearTimeout(giveUp);
|
||||
fn();
|
||||
};
|
||||
const onChunk = (b: Buffer) => {
|
||||
out += b.toString();
|
||||
const url = extractLoginUrl(out);
|
||||
if (url) settle(() => resolve({ url, out }));
|
||||
};
|
||||
child.stdout.on('data', onChunk);
|
||||
// The CLI prints the URL to stdout, but its warnings go to stderr and one of them explains a failure.
|
||||
child.stderr.on('data', (b: Buffer) => {
|
||||
out += b.toString();
|
||||
});
|
||||
child.on('error', (err) => settle(() => reject(err)));
|
||||
child.on('exit', (code) =>
|
||||
settle(() => reject(new Error(`claude auth login exited ${code} before printing a URL: ${visibleText(out)}`))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const claudeLoginRouter = createRouter();
|
||||
|
||||
// POST /auth/claude-login — start one, and hand back the URL to open.
|
||||
claudeLoginRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who: Who = { id: user.id, isOwner: user.id === 1, osUser: user.osUser ?? null, email: user.email };
|
||||
|
||||
// One at a time per account. A second `/login` while one is open is a person retrying, not a person
|
||||
// wanting two, and two processes racing to write one credentials file is a corrupt file.
|
||||
for (const [id, p] of pending) if (p.userId === who.id) abandon(id);
|
||||
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = spawnLogin(who);
|
||||
} catch (err) {
|
||||
return ctx.json({ error: err instanceof Error ? err.message : 'could not start the login' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, out } = await urlFrom(child);
|
||||
const id = crypto.randomUUID();
|
||||
pending.set(id, { userId: who.id, child, url, out, reaper: setTimeout(() => abandon(id), TTL_MS) });
|
||||
// Keep reading, or the pipe fills and the child blocks — and we want its later output for the result.
|
||||
child.stdout.on('data', (b: Buffer) => {
|
||||
const p = pending.get(id);
|
||||
if (p) p.out += b.toString();
|
||||
});
|
||||
return ctx.json({ id, url, expiresInMs: TTL_MS });
|
||||
} catch (err) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// Nothing to clean up.
|
||||
}
|
||||
return ctx.json({ error: err instanceof Error ? err.message : 'the login did not start' }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /auth/claude-login/:id/code — hand over what claude.com gave them.
|
||||
claudeLoginRouter.post('/:id/code', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const p = pending.get(id);
|
||||
// Same answer for "expired", "never existed" and "somebody else's": all three are equally not-yours.
|
||||
if (!p || p.userId !== user.id) return ctx.json({ error: 'that login is no longer open — run /login again' }, 404);
|
||||
|
||||
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
||||
if (!code) return ctx.json({ error: 'no code' }, 400);
|
||||
|
||||
const finished = new Promise<number | null>((resolve) => p.child.once('exit', (c) => resolve(c)));
|
||||
p.child.stdin.write(`${code}\n`);
|
||||
|
||||
// The exchange is a network round trip on the CLI's side.
|
||||
const exit = await Promise.race([finished, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 45_000))]);
|
||||
const tail = visibleText(p.out).slice(-600);
|
||||
abandon(id);
|
||||
|
||||
if (exit === 'timeout') return ctx.json({ ok: false, error: 'the CLI did not finish in 45s', detail: tail }, 504);
|
||||
if (exit !== 0) return ctx.json({ ok: false, error: `claude auth login exited ${exit}`, detail: tail }, 400);
|
||||
|
||||
// Record what was just signed into, so it can be switched BACK to later without another round trip. Only
|
||||
// for the owner: a member's credential is in their own home and this module only ever reads the owner's.
|
||||
if (user.id === 1) await captureCurrentAnthropicAccount(user.id);
|
||||
const now = user.id === 1 ? await readCurrentAccount() : null;
|
||||
|
||||
// The account is the answer the person opened this for. Two accounts on one machine means "signed in" on
|
||||
// its own is not enough to act on.
|
||||
return ctx.json({ ok: true, detail: tail, email: now?.emailAccount ?? null, orgName: now?.orgName ?? null });
|
||||
});
|
||||
|
||||
// DELETE /auth/claude-login/:id — they closed the dialog.
|
||||
claudeLoginRouter.delete('/:id', (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
const p = pending.get(id);
|
||||
if (p && p.userId === user.id) abandon(id);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Switching between accounts that have already been signed into.
|
||||
|
||||
The owner keeps a personal account and a company one and moves between them during a day. `/login` above
|
||||
handles arriving at an account; this handles going back to one, without a browser round trip, using the
|
||||
credential kept from last time.
|
||||
|
||||
Owner only, and structurally: `getOwnerHomeDir` ignores the email it is passed, so every path below is
|
||||
the owner's whatever the caller claims. A member's `claude` reads their own home and is untouched by any
|
||||
of this — the isolation is the same as it was, and this feature does not widen it.
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** `.credentials.json` holds one key. `.claude.json` holds a whole config and must be PATCHED, not written. */
|
||||
const credentialsPath = () => join(getOwnerHomeDir(''), '.claude', '.credentials.json');
|
||||
const configPath = () => join(getOwnerHomeDir(''), '.claude.json');
|
||||
|
||||
async function readJson(path: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the CLI is signed in as right now, read from its own two files.
|
||||
*
|
||||
* Both are needed and neither is sufficient: the credential lives in `.claude/.credentials.json` and the
|
||||
* identity — email, org, account uuid — lives in `.claude.json` beside it.
|
||||
*/
|
||||
async function readCurrentAccount(): Promise<{
|
||||
accountUuid: string;
|
||||
emailAccount: string;
|
||||
orgName: string | null;
|
||||
plan: string | null;
|
||||
credentialsJson: string;
|
||||
oauthAccountJson: string;
|
||||
refreshExpiresAt: Date | null;
|
||||
} | null> {
|
||||
const creds = await readJson(credentialsPath());
|
||||
const config = await readJson(configPath());
|
||||
const oauth = creds?.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
const account = config?.oauthAccount as Record<string, unknown> | undefined;
|
||||
if (!oauth || !account) return null;
|
||||
|
||||
const accountUuid = typeof account.accountUuid === 'string' ? account.accountUuid : '';
|
||||
const emailAccount = typeof account.emailAddress === 'string' ? account.emailAddress : '';
|
||||
if (!accountUuid || !emailAccount) return null;
|
||||
|
||||
const sub = typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : '';
|
||||
const refreshMs = typeof oauth.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null;
|
||||
return {
|
||||
accountUuid,
|
||||
emailAccount,
|
||||
orgName: typeof account.organizationName === 'string' ? account.organizationName : null,
|
||||
plan: sub ? sub[0]!.toUpperCase() + sub.slice(1) : null,
|
||||
credentialsJson: JSON.stringify(oauth),
|
||||
oauthAccountJson: JSON.stringify(account),
|
||||
refreshExpiresAt: refreshMs ? new Date(refreshMs) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep whatever the CLI is currently signed in as.
|
||||
*
|
||||
* Called after a `/login` here AND whenever the account list is read, which is what makes an account signed
|
||||
* into from a TERMINAL switchable too. Without that second call this would only know about logins that
|
||||
* happened to go through this dialog — a switcher that silently omits half your accounts.
|
||||
*
|
||||
* Never throws into a caller: failing to record a credential must not fail the request that noticed it.
|
||||
*/
|
||||
export async function captureCurrentAnthropicAccount(userId: number): Promise<void> {
|
||||
try {
|
||||
const now = await readCurrentAccount();
|
||||
if (!now) return;
|
||||
await saveAnthropicAccount({ userId, ...now });
|
||||
} catch (err) {
|
||||
console.warn('[claude-auth] could not record the current account:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /claude-auth/accounts — who can be switched to, and who is current. No secrets.
|
||||
claudeLoginRouter.get('/accounts', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
|
||||
|
||||
// Record before listing, so an account signed into elsewhere appears the first time the panel is opened
|
||||
// rather than only after the next login through this dialog.
|
||||
await captureCurrentAnthropicAccount(user.id);
|
||||
await purgeExpiredAnthropicAccounts(user.id).catch(() => 0);
|
||||
|
||||
const current = await readCurrentAccount();
|
||||
const accounts = (await listAnthropicAccounts(user.id)).map((a) => ({
|
||||
...a,
|
||||
current: a.accountUuid === current?.accountUuid,
|
||||
}));
|
||||
return ctx.json({ accounts });
|
||||
});
|
||||
|
||||
// POST /claude-auth/switch — become one of them.
|
||||
claudeLoginRouter.post('/switch', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
|
||||
|
||||
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const accountUuid = typeof body.accountUuid === 'string' ? body.accountUuid : '';
|
||||
if (!accountUuid) return ctx.json({ error: 'no account' }, 400);
|
||||
|
||||
// Keep the outgoing credential before overwriting it, or switching away from an account signed into
|
||||
// outside this UI loses it — the one-way trip this feature exists to remove.
|
||||
await captureCurrentAnthropicAccount(user.id);
|
||||
|
||||
const target = await revealAnthropicAccount(user.id, accountUuid);
|
||||
if (!target) return ctx.json({ error: 'that sign-in has expired — run /login for it again' }, 409);
|
||||
|
||||
try {
|
||||
// `.credentials.json` is replaced wholesale: it holds this one key and nothing else of ours.
|
||||
// `.claude.json` is PATCHED — it is the CLI's whole config, and overwriting it would take out every
|
||||
// project entry, tip counter and preference in it.
|
||||
const existingCreds = (await readJson(credentialsPath())) ?? {};
|
||||
await writeFile(
|
||||
credentialsPath(),
|
||||
JSON.stringify({ ...existingCreds, claudeAiOauth: JSON.parse(target.credentialsJson) }),
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
|
||||
const config = (await readJson(configPath())) ?? {};
|
||||
await writeFile(
|
||||
configPath(),
|
||||
JSON.stringify({ ...config, oauthAccount: JSON.parse(target.oauthAccountJson) }, null, 2),
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
return ctx.json({ error: `could not write the credential: ${err instanceof Error ? err.message : err}` }, 500);
|
||||
}
|
||||
|
||||
await markAnthropicAccountUsed(user.id, accountUuid);
|
||||
// A turn already running keeps the credential it was spawned with. Said here rather than guessed at in
|
||||
// the UI, because "it did not take effect" and "it takes effect next turn" look identical from outside.
|
||||
return ctx.json({ ok: true, email: target.emailAccount, appliesTo: 'the next turn, and every new claude' });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import { queueRouter } from './api/queue/queue';
|
||||
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { agentStatusRouter } from './api/agent-status/router';
|
||||
import { claudeLoginRouter } from './api/auth/claude-login';
|
||||
import { chatRouter } from './api/chat/chat';
|
||||
import { commandHqRouter } from './api/command-hq/router';
|
||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||
@@ -149,6 +150,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/queue', queueRouter],
|
||||
['/bug-report', bugReportRouter],
|
||||
['/agent-status', agentStatusRouter],
|
||||
['/claude-auth', claudeLoginRouter],
|
||||
['/chat', chatRouter],
|
||||
['/command-hq', commandHqRouter],
|
||||
['/pipeline-jobs', pipelineJobsRouter],
|
||||
|
||||
@@ -278,7 +278,17 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports
|
||||
// two booleans about the caller's own home and nothing about anyone else, which is what lets a member be
|
||||
// told "run `claude` once to sign in" instead of being shown a tile that 403s.
|
||||
api: ['/chat', '/agent-status'],
|
||||
// `/claude-auth` is the `/login` flow — it spawns `claude auth login` AS THE CALLER and writes the
|
||||
// credential into the caller's own home. It rides this permission rather than earning its own for the
|
||||
// reason that makes the pairing exact: an account that may use the agent must be able to authenticate
|
||||
// the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest
|
||||
// for free — `authorize.ts` drops a confined grant for an account with no `osUser`, which is precisely
|
||||
// the account that cannot hold credentials of its own.
|
||||
//
|
||||
// NOT under `/auth`, which `totality.ts` exempts from the gate entirely because it is where a caller
|
||||
// goes to BECOME authenticated. Mounted there, an unauthenticated request could start a login as the
|
||||
// owner.
|
||||
api: ['/chat', '/agent-status', '/claude-auth'],
|
||||
// 'chat-notify' is a session-independent socket (the bell feature): it carries no sessionId and no
|
||||
// transcript, just a ping that a chat this account can already reach has finished a turn or needs
|
||||
// input. Riding this permission rather than a new one, because it is meaningless without `chat`
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
* is not localhost the whole object is undefined, so `navigator.clipboard.writeText`
|
||||
* throws `TypeError: Cannot read properties of undefined`.
|
||||
*
|
||||
* Officer is reached over the tailnet — `http://officer-dev:9000` — which is
|
||||
* neither https nor localhost. Twenty call sites were affected, in three states
|
||||
* that all looked fine in review:
|
||||
* Officer is reachable over plain http — `http://officer-dev:9000` on the tailnet —
|
||||
* which is neither https nor localhost. The owner's own browsing is over https
|
||||
* (`platform.officer.dev`) and was already https when this was written; the http
|
||||
* origins are the other ways in, and an install with no certificate has no other
|
||||
* way in at all. Twenty call sites were affected, in three states that all looked
|
||||
* fine in review:
|
||||
*
|
||||
* bare `navigator.clipboard.writeText(x)` threw, killing the handler
|
||||
* `navigator.clipboard?.writeText(x)` did nothing, silently
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
/*
|
||||
`/login` — the same flow as the terminal, without the terminal.
|
||||
|
||||
Officer authenticates `claude` with a Claude SUBSCRIPTION over OAuth, which is a browser round trip:
|
||||
open a URL, authorise, paste a code back. Until now the only way to do that here was to open a terminal
|
||||
panel and drive the CLI by hand — which works, and is a strange thing to ask of somebody whose whole
|
||||
session is a browser.
|
||||
|
||||
Three states, and the middle one is the person leaving to do something else:
|
||||
|
||||
starting → waiting for the code → done
|
||||
*/
|
||||
|
||||
type Started = { id: string; url: string; expiresInMs: number };
|
||||
|
||||
type Props = { open: boolean; onOpenChange: (open: boolean) => void };
|
||||
|
||||
export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
const client = useClient();
|
||||
const [started, setStarted] = useState<Started | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Start on open, and abandon on close. The server holds a live `claude` process per pending login, so a
|
||||
// dialog dismissed without telling it leaves that process parked on a read of stdin.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setStarted(null);
|
||||
setCode('');
|
||||
setError(null);
|
||||
setDone(false);
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await client.post<Started>('/claude-auth', {});
|
||||
if (!cancelled) setStarted(res);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorText(err, 'could not start the login'));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && started) void client.delete(`/claude-auth/${started.id}`).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!started || !code.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.post(`/claude-auth/${started.id}/code`, { code: code.trim() });
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
setError(errorText(err, 'that code was not accepted'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Claude</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{done ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
Signed in. New turns use these credentials — no restart needed.
|
||||
</div>
|
||||
) : !started ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
{error ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Asking the CLI for a sign-in link…
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open this, authorise, and paste back the code it gives you.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* A real anchor. Every link here opens a new tab, which is what you want for this one —
|
||||
losing the dialog would lose the process holding the other half of the exchange. */}
|
||||
<a
|
||||
href={started.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Authorise in claude.com
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{/* Copy, because opening here opens in THIS browser — already signed into whichever account
|
||||
you are trying to move away from. Pasting into another profile or another device is how
|
||||
you sign in as somebody else, and this dialog exists precisely for the machine with two
|
||||
Anthropic accounts on it. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void copyToClipboard(started.url).then((ok) => {
|
||||
setCopied(ok);
|
||||
if (ok) setTimeout(() => setCopied(false), 2_000);
|
||||
else setError('could not reach the clipboard — select the link and copy it by hand');
|
||||
});
|
||||
}}
|
||||
title="Copy the link, to open it in another browser or on your phone"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-foreground/5"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
ref={codeRef}
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') void submit();
|
||||
}}
|
||||
placeholder="paste the code"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expires in {Math.round(started.expiresInMs / 60_000)} minutes. Run <code>/login</code> again if it does.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!code.trim() || busy}
|
||||
onClick={() => void submit()}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-40"
|
||||
>
|
||||
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||
import { armBackgroundJob } from '../../../background-job-origin';
|
||||
import { useWorkspace } from '../../../components/Workspace';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
@@ -17,6 +18,7 @@ type InputAreaProps = {
|
||||
};
|
||||
|
||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -105,7 +107,17 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={(ev) => {
|
||||
// Enter is how anybody actually sends, so the interception has to be here as well as on the
|
||||
// button. Shift-Enter is a newline and stays one.
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && input.trim() === '/login') {
|
||||
ev.preventDefault();
|
||||
setInput('');
|
||||
setLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
handleKeyDown(ev);
|
||||
}}
|
||||
onPaste={(ev) => {
|
||||
// Let an image paste fall through to the browser's default (nothing) rather than attaching
|
||||
// something this model will discard.
|
||||
@@ -141,7 +153,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => {
|
||||
// `/login` never reaches the agent. It is not a prompt — it is the browser-only user doing the
|
||||
// one thing that used to need a terminal panel, so the composer answers it locally.
|
||||
if (input.trim() === '/login') {
|
||||
setInput('');
|
||||
setLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
}}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
|
||||
@@ -169,10 +190,21 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
<ClaudeLoginDialog open={loginOpen} onOpenChange={setLoginOpen} />
|
||||
<BackgroundJobDialog
|
||||
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,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { UsageAccount } from '../useClaudeUsage';
|
||||
import { currentLimit, formatAge, formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
|
||||
import type { SwitchableAccount } from '../useAnthropicAccounts';
|
||||
import { useAnthropicAccounts } from '../useAnthropicAccounts';
|
||||
|
||||
/**
|
||||
* Claude Code's own usage display, on the harness pill — for every account, not just the one signed in.
|
||||
@@ -129,38 +132,70 @@ const Models = ({ account }: { account: UsageAccount }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* One chip per account, and only when there is more than one.
|
||||
* A chip per Anthropic account this machine has seen, and clicking one does one of two things.
|
||||
*
|
||||
* A tab strip over a single account is chrome that never does anything — the same reasoning that collapses
|
||||
* the provider strip to a label when there is one harness on offer.
|
||||
* switch — a stored credential exists, so the click BECOMES that account
|
||||
* view — only a stored reading exists, so the click shows its cached numbers and nothing changes
|
||||
*
|
||||
* The second kind is not a degraded version of the first, it is a different question. An account whose
|
||||
* sign-in is gone still has percentages and a reset time worth reading — "has my other allowance come back
|
||||
* yet" is answerable from a snapshot, because a reset time is an absolute instant and `currentLimit` rolls a
|
||||
* past window forward rather than showing a stale one. What you cannot do is act on the answer without a
|
||||
* `/login`, and that is what the chip has to make obvious BEFORE it is clicked.
|
||||
*
|
||||
* So: filled for the account you are, plain for one you can become, and dashed-outline for one you can only
|
||||
* look at.
|
||||
*/
|
||||
const AccountTabs = ({
|
||||
accounts,
|
||||
selected,
|
||||
onSelect,
|
||||
type Chip = {
|
||||
key: string;
|
||||
email: string;
|
||||
title: string;
|
||||
kind: 'current' | 'switch' | 'view';
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
const AccountChips = ({
|
||||
chips,
|
||||
isSwitching,
|
||||
viewing,
|
||||
onClick,
|
||||
}: {
|
||||
accounts: UsageAccount[];
|
||||
selected: string;
|
||||
onSelect: (email: string) => void;
|
||||
chips: Chip[];
|
||||
isSwitching: boolean;
|
||||
viewing: string | null;
|
||||
onClick: (chip: Chip) => void;
|
||||
}) => (
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-t border-border px-2 py-2">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
key={account.email}
|
||||
type="button"
|
||||
onClick={() => onSelect(account.email)}
|
||||
// The local part alone: the domain is what the accounts have in common and the part before it is
|
||||
// what tells them apart, and the panel is 320px wide.
|
||||
title={account.email}
|
||||
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
account.email === selected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{account.email.split('@')[0]}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-1 border-t border-border px-2 py-2">
|
||||
{chips.map((chip) => {
|
||||
const looking = chip.kind === 'view' && viewing === chip.email;
|
||||
return (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
disabled={isSwitching || (chip.kind === 'current' && !viewing)}
|
||||
onClick={() => onClick(chip)}
|
||||
title={chip.title}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
chip.kind === 'current' && !viewing
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: chip.kind === 'view'
|
||||
? // Dashed and unfilled: the border says "not a real destination" without greying it out to
|
||||
// the point of looking broken, because it IS clickable — just not for switching.
|
||||
`cursor-pointer border border-dashed border-border ${
|
||||
looking ? 'bg-foreground/10 text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
|
||||
}`
|
||||
: 'cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
|
||||
} ${isSwitching && !chip.busy ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{chip.busy && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{/* The WHOLE address. This showed the local part alone, on the reasoning that the domain is what
|
||||
accounts have in common — which is wrong for the case that matters: a personal and a work
|
||||
account belonging to one person share the local part, so the domain is the half that tells
|
||||
them apart. Two chips read as the same account. */}
|
||||
{chip.email}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -193,7 +228,8 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
const enabled = isOwner && harness === 'claude-code';
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const { accounts, isLoading, isRefreshing, isError } = useClaudeUsage(enabled && open);
|
||||
const { accounts: switchable, isSwitching, switchingTo, switchTo, switchError } = useAnthropicAccounts(open);
|
||||
const { accounts, allAccounts, isLoading, isRefreshing, isError } = useClaudeUsage(enabled && open);
|
||||
|
||||
/*
|
||||
Which tab is showing.
|
||||
@@ -204,8 +240,57 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
Null means "whichever is current", resolved at render — so the panel opens on the signed-in account
|
||||
without needing an effect to select it once the data arrives.
|
||||
*/
|
||||
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||
const selected = accounts.find((a) => a.email === selectedEmail) ?? accounts[0] ?? null;
|
||||
/*
|
||||
No selection state any more, deliberately.
|
||||
|
||||
The panel shows the account you are SIGNED INTO, full stop, and `useClaudeUsage` already filters to it.
|
||||
A selection would let the numbers and the chips disagree — reading one account's percentages with
|
||||
another's chip lit — and that disagreement is the exact confusion this whole change set out to remove.
|
||||
*/
|
||||
/*
|
||||
`viewing` is null for the normal case — the account you are signed into — and an email when you have
|
||||
clicked an archived chip to read its cached numbers.
|
||||
|
||||
It is deliberately NOT a general tab selection. Only accounts with no stored credential can be viewed,
|
||||
because for any other one the useful gesture is switching to it and then reading live numbers. That keeps
|
||||
the invariant that made phase one worth doing: if the panel shows a signed-in account, it is THE
|
||||
signed-in account.
|
||||
*/
|
||||
const [viewing, setViewing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) setViewing(null);
|
||||
}, [open]);
|
||||
|
||||
const selected = (viewing ? allAccounts.find((a) => a.email === viewing) : accounts[0]) ?? accounts[0] ?? null;
|
||||
/*
|
||||
Accounts with a stored READING but no stored credential — an old login that can be looked at and not
|
||||
returned to. That is what is worth saying out loud.
|
||||
|
||||
This counted `allAccounts - accounts` until the switcher existed, which then double-counted: it called
|
||||
an account "not shown" while its chip sat two lines below.
|
||||
*/
|
||||
const archived = allAccounts.filter((a) => !switchable.some((s) => s.emailAccount === a.email));
|
||||
|
||||
const chips: Chip[] = [
|
||||
...switchable.map((a) => ({
|
||||
key: a.accountUuid,
|
||||
email: a.emailAccount,
|
||||
kind: (a.current ? 'current' : 'switch') as Chip['kind'],
|
||||
busy: isSwitching && switchingTo === a.accountUuid,
|
||||
title: a.current
|
||||
? `${a.emailAccount} — signed in`
|
||||
: a.usable
|
||||
? `Switch to ${a.emailAccount}${a.orgName ? ` (${a.orgName})` : ''}`
|
||||
: `${a.emailAccount} — sign-in expired, run /login`,
|
||||
})),
|
||||
...archived.map((a) => ({
|
||||
key: `archived:${a.email}`,
|
||||
email: a.email,
|
||||
kind: 'view' as Chip['kind'],
|
||||
busy: false,
|
||||
title: `${a.email} — no saved sign-in. Click to read its last known usage; run /login to use it.`,
|
||||
})),
|
||||
];
|
||||
|
||||
// The countdowns tick. Only while the panel is open — an interval running behind a closed popover is a
|
||||
// re-render a minute for something nobody is looking at.
|
||||
@@ -234,13 +319,32 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
{/* Plan and account in the accent rather than muted: this line is the ANSWER to "whose numbers am
|
||||
I looking at", which matters more the moment there is a second tab, and it was reading as
|
||||
chrome under the title. */}
|
||||
<div className="truncate text-xs text-primary">
|
||||
{selected
|
||||
? [selected.plan, selected.email].filter(Boolean).join(' · ')
|
||||
: isLoading
|
||||
? 'Reading usage…'
|
||||
: 'Not signed in'}
|
||||
</div>
|
||||
{/* The email on its own line, unshared. It is the answer to the only question this panel is
|
||||
being opened to settle — which account am I spending — and joined with the plan it was the
|
||||
half that got truncated first in a 320px popover. */}
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="truncate text-xs font-medium text-primary" title={selected.email}>
|
||||
{selected.email}
|
||||
</div>
|
||||
{selected.plan && <div className="text-[11px] text-muted-foreground">{selected.plan}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-primary">{isLoading ? 'Reading usage…' : 'Not signed in'}</div>
|
||||
)}
|
||||
{/* Said out loud rather than silently dropped. Phase one shows one account; knowing another
|
||||
exists and is hidden on purpose is different from wondering where it went. */}
|
||||
{switchError && (
|
||||
<div className="mt-1 text-[11px] text-destructive">
|
||||
{switchError instanceof Error ? switchError.message : 'could not switch account'}
|
||||
</div>
|
||||
)}
|
||||
{/* Said on the account being LOOKED AT rather than as a count somewhere else. The numbers below are
|
||||
real and the reset times are still correct; the only thing missing is the ability to act on
|
||||
them, and naming the fix beats naming the problem. */}
|
||||
{viewing && (
|
||||
<div className="mt-0.5 text-[10px] text-warning">No saved sign-in — run /login to use this one</div>
|
||||
)}
|
||||
{/*
|
||||
How old this reading is, and it earns its line only when it is not live.
|
||||
|
||||
@@ -258,8 +362,24 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<AccountTabs accounts={accounts} selected={selected?.email ?? ''} onSelect={setSelectedEmail} />
|
||||
{chips.length > 1 && (
|
||||
<AccountChips
|
||||
chips={chips}
|
||||
isSwitching={isSwitching}
|
||||
viewing={viewing}
|
||||
onClick={(chip) => {
|
||||
if (chip.kind === 'view') {
|
||||
setViewing(chip.email);
|
||||
return;
|
||||
}
|
||||
// Switching returns the panel to live numbers: the account you just became is the one whose
|
||||
// percentages you want, and leaving an archived snapshot on screen after a switch is how the
|
||||
// numbers and the chips end up disagreeing.
|
||||
setViewing(null);
|
||||
const target = switchable.find((a) => a.emailAccount === chip.email);
|
||||
if (target && !target.current) void switchTo(target.accountUuid).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A failure never blanks the panel: the two halves are independent server-side, so a limits problem
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
/*
|
||||
The Anthropic accounts this machine's owner can BE, and the act of becoming one.
|
||||
|
||||
Separate from `useClaudeUsage`, which answers a different question. Usage is a measurement — how much room
|
||||
is left — and is only readable for the account currently signed in. This is identity: who could I be, and
|
||||
make me them. They meet in the popover, where the numbers belong to whichever pill is lit.
|
||||
*/
|
||||
|
||||
export type SwitchableAccount = {
|
||||
accountUuid: string;
|
||||
emailAccount: string;
|
||||
orgName: string | null;
|
||||
plan: string | null;
|
||||
capturedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
refreshExpiresAt: string | null;
|
||||
/** False once the refresh token has expired: the pill has to offer `/login` rather than a switch. */
|
||||
usable: boolean;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
const KEY = ['anthropic', 'accounts'];
|
||||
|
||||
export function useAnthropicAccounts(enabled: boolean) {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery<{ accounts: SwitchableAccount[] }>({
|
||||
queryKey: KEY,
|
||||
queryFn: () => client.get<{ accounts: SwitchableAccount[] }>('/claude-auth/accounts'),
|
||||
enabled,
|
||||
// Cheap — two small file reads and one SELECT — and it must not be stale, because a switch made from a
|
||||
// terminal is exactly the case this is here to notice.
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
// A member gets a 403 here by design. Retrying it just spends three round trips arriving at the same no.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const switchTo = useMutation({
|
||||
mutationFn: (accountUuid: string) =>
|
||||
client.post<{ ok: boolean; email: string; appliesTo: string }>('/claude-auth/switch', { accountUuid }),
|
||||
onSuccess: async () => {
|
||||
// Both, and in this order. The account list decides which pill is lit; the usage numbers belong to
|
||||
// whichever account that is, and showing the old account's percentages under the new name is the one
|
||||
// outcome that would make somebody distrust the whole panel.
|
||||
await queryClient.invalidateQueries({ queryKey: KEY });
|
||||
await queryClient.invalidateQueries({ queryKey: ['chat', 'usage'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['chat', 'usage', 'fresh'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accounts: query.data?.accounts ?? [],
|
||||
isLoading: query.isLoading,
|
||||
/** True while a switch is in flight — the panel must say so, or nobody believes it happened. */
|
||||
isSwitching: switchTo.isPending,
|
||||
switchingTo: switchTo.variables ?? null,
|
||||
switchError: switchTo.error,
|
||||
switchTo: switchTo.mutateAsync,
|
||||
};
|
||||
}
|
||||
@@ -76,8 +76,27 @@ export function useClaudeUsage(enabled: boolean) {
|
||||
if (fresh.data) queryClient.setQueryData(KEY, fresh.data);
|
||||
}, [fresh.data, queryClient]);
|
||||
|
||||
const all = fresh.data?.accounts ?? cached.data?.accounts ?? [];
|
||||
|
||||
/*
|
||||
PHASE ONE — the signed-in account only. Temporary, and a one-line revert: delete `onlyCurrent` and
|
||||
return `all`.
|
||||
|
||||
The stored snapshots for other accounts are a real feature and the server still serves them. The problem
|
||||
is what they do to the question being asked. This machine's owner has a personal account and a
|
||||
work one, both appear as tabs, and after signing in you cannot tell from a glance WHICH tab you are
|
||||
now spending — the panel answers "here is usage for two accounts" when the question is "whose am I on".
|
||||
|
||||
So while that is the question, show one account and let its absence mean something: no row marked
|
||||
`current` renders as "Not signed in", which is a true and useful answer rather than a tab that happens
|
||||
to be selected.
|
||||
*/
|
||||
const onlyCurrent = all.filter((a) => a.current);
|
||||
|
||||
return {
|
||||
accounts: fresh.data?.accounts ?? cached.data?.accounts ?? [],
|
||||
accounts: onlyCurrent,
|
||||
/** Every account the server knows about — kept so a caller can say how many are being hidden. */
|
||||
allAccounts: all,
|
||||
/** Only true when there is nothing at all to show. A stale list is not a loading state. */
|
||||
isLoading: cached.isLoading && fresh.isLoading,
|
||||
/** A refresh in flight over an already-painted panel. Worth a hint, never a spinner over the numbers. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from './internal/resizable';
|
||||
import { useIsMobile } from './internal/useIsMobile';
|
||||
@@ -296,28 +296,29 @@ export const WorkspaceView = ({
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [swapSourceId, dragSourceId]);
|
||||
|
||||
const ephemeralPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
|
||||
// ── The ephemeral pane is open when it EXISTS, and that is its only representation ──
|
||||
//
|
||||
// It used to be a permanent panel starting at `defaultSize={0}`, opened by an effect calling
|
||||
// `panel.resize()`. That put the state in two places — the prop said "closed", the effect was supposed
|
||||
// to correct it — and a correction step is something that can be missed. It was:
|
||||
//
|
||||
// Landing on `/files/...?view=x.m4a` (a deep link, or just a refresh) made `isEphemeralOpen` true on
|
||||
// the FIRST render. But `workspace.isLoaded` is false until `/dashboards` answers, and the early return
|
||||
// below means nothing is rendered yet — so the effect ran with a null ref and returned. Its deps were
|
||||
// `[isEphemeralOpen, defaultBaseSize]`, and by the time the group did render neither had changed: one
|
||||
// was already true, the other is a constant. So it never ran again. The URL said the viewer was open,
|
||||
// the pane sat at zero width, and double-clicking the file did nothing at all — `?view=` already held
|
||||
// that name, so there was no state change to react to. Reported as "refresh loses the panel and then
|
||||
// the file won't open".
|
||||
//
|
||||
// Rendering the panel conditionally cannot have that failure: there is no moment where the markup and
|
||||
// the intent disagree, and a deep link is correct on first paint rather than corrected afterwards.
|
||||
// `id` + `order` are what make a conditional panel safe in react-resizable-panels 2.x — the same pair
|
||||
// `WorkspaceRenderer` already uses for the panel tree.
|
||||
const isEphemeralOpen = !!ephemeral;
|
||||
|
||||
useEffect(() => {
|
||||
const panel = ephemeralPanelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
// `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not
|
||||
// laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole
|
||||
// dashboard rather than the pane that could not be sized. The pane is a file preview; the panels
|
||||
// behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next
|
||||
// render tries again.
|
||||
try {
|
||||
if (isEphemeralOpen) {
|
||||
panel.resize(100 - (ephemeral.defaultBaseSize ?? 40));
|
||||
} else {
|
||||
panel.collapse();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[workspace] could not size the ephemeral pane yet', err);
|
||||
}
|
||||
}, [isEphemeralOpen, ephemeral?.defaultBaseSize]);
|
||||
// The workspace's share of the group when the ephemeral pane is open; the pane takes the remainder.
|
||||
const baseSize = ephemeral?.defaultBaseSize ?? 40;
|
||||
|
||||
const onMobileBack = useMemo(() => {
|
||||
if (!isMobile) return null;
|
||||
@@ -390,7 +391,7 @@ export const WorkspaceView = ({
|
||||
/>
|
||||
) : (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel defaultSize={100} minSize={15}>
|
||||
<ResizablePanel id="workspace-base" order={1} defaultSize={isEphemeralOpen ? baseSize : 100} minSize={15}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
@@ -405,20 +406,22 @@ export const WorkspaceView = ({
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
||||
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
|
||||
{isEphemeralOpen ? (
|
||||
<WorkspaceRenderer
|
||||
layout={ephemeral.layout}
|
||||
registry={registry}
|
||||
components={ephemeral.components}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={noop}
|
||||
/>
|
||||
) : null}
|
||||
</ResizablePanel>
|
||||
{isEphemeralOpen && ephemeral ? (
|
||||
<>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" />
|
||||
<ResizablePanel id="workspace-ephemeral" order={2} defaultSize={100 - baseSize} minSize={15}>
|
||||
<WorkspaceRenderer
|
||||
layout={ephemeral.layout}
|
||||
registry={registry}
|
||||
components={ephemeral.components}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={noop}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
) : null}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</WorkspaceProvider>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { LayoutNode } from '../core/types';
|
||||
import type { AppRegistryMap, DashboardState, EphemeralPanels } from './types';
|
||||
import { WorkspaceView } from './WorkspaceView';
|
||||
|
||||
/**
|
||||
* The ephemeral pane — the file viewer that opens beside a workspace — and whether it is OPEN.
|
||||
*
|
||||
* Every bug this has had looked identical from outside: the URL says a file is open, the pane is not
|
||||
* there, and clicking the file again does nothing, because the URL already says what the click would say.
|
||||
* The cause was the same shape each time — open/closed expressed twice, once in the markup and once in an
|
||||
* effect meant to correct it, with the correction able to run at a moment where it could not take effect.
|
||||
*
|
||||
* So these assert the SIZE the group gives the pane, never merely that the element exists. A pane
|
||||
* rendered at zero width is precisely the failure being guarded against, and "is it in the document"
|
||||
* reports that as a pass. `data-panel-size` is react-resizable-panels' own computed answer.
|
||||
*
|
||||
* Each test renders its own tree: `@testing-library/react` unmounts between tests, so a render shared by
|
||||
* a describe block is detached by the time the second test reads it.
|
||||
*/
|
||||
|
||||
const BASE_PANEL: LayoutNode = { type: 'panel', id: 'p1', appType: 'test/app' };
|
||||
|
||||
const dashboard = (isLoaded: boolean): DashboardState => ({
|
||||
key: 'screens/test',
|
||||
value: BASE_PANEL,
|
||||
setValue: () => {},
|
||||
isLoaded,
|
||||
});
|
||||
|
||||
const registry = {
|
||||
'test/app': { name: 'T', icon: () => null, component: () => <div>BASE</div> },
|
||||
} as unknown as AppRegistryMap;
|
||||
|
||||
const viewer: EphemeralPanels = {
|
||||
layout: { type: 'panel', id: 'files-viewer', appType: null },
|
||||
components: { 'files-viewer': { component: () => <div>VIEWER</div> } },
|
||||
defaultBaseSize: 40,
|
||||
};
|
||||
|
||||
/** The group's own computed share for one pane, or null when the pane is not in the tree. */
|
||||
const paneSize = (container: HTMLElement, id: string): number | null => {
|
||||
const el = container.querySelector(`[data-panel-id="${id}"]`);
|
||||
const size = el?.getAttribute('data-panel-size');
|
||||
return size == null ? null : Number(size);
|
||||
};
|
||||
|
||||
describe('nothing open', () => {
|
||||
test('the pane is not in the tree at all', () => {
|
||||
const { container } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} />);
|
||||
expect(paneSize(container, 'workspace-ephemeral')).toBeNull();
|
||||
});
|
||||
|
||||
test('the workspace has the whole group', () => {
|
||||
const { container } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} />);
|
||||
expect(paneSize(container, 'workspace-base')).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a deep link — open on the very first render', () => {
|
||||
test('the pane is open on first paint rather than corrected afterwards', () => {
|
||||
const { container } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={viewer} />);
|
||||
expect(paneSize(container, 'workspace-ephemeral')).toBe(60);
|
||||
expect(paneSize(container, 'workspace-base')).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
// THE REGRESSION, and the reason this file exists. `workspace.isLoaded` is false until /dashboards
|
||||
// answers, and WorkspaceView renders nothing at all until then — so a `?view=` present at load time was
|
||||
// already "open" before there was any panel group to size. The old imperative effect ran once against a
|
||||
// null ref and returned; its dependencies (already-true `isEphemeralOpen`, a constant size) could not
|
||||
// change afterwards, so it never ran again. URL open, pane at zero width, and clicking the file inert
|
||||
// because the URL needed no change.
|
||||
describe('open before the dashboard has finished loading', () => {
|
||||
test('the pane opens when the group appears, with no state change to trigger it', () => {
|
||||
const { container, rerender } = render(
|
||||
<WorkspaceView workspace={dashboard(false)} registry={registry} ephemeral={viewer} />,
|
||||
);
|
||||
expect(paneSize(container, 'workspace-base')).toBeNull(); // nothing renders while loading
|
||||
|
||||
rerender(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={viewer} />);
|
||||
|
||||
expect(paneSize(container, 'workspace-ephemeral')).toBe(60);
|
||||
expect(paneSize(container, 'workspace-base')).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opening and closing while the page is live', () => {
|
||||
test('opening gives the pane its share and closing gives it back', () => {
|
||||
const { container, rerender } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} />);
|
||||
expect(paneSize(container, 'workspace-base')).toBe(100);
|
||||
|
||||
rerender(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={viewer} />);
|
||||
expect(paneSize(container, 'workspace-ephemeral')).toBe(60);
|
||||
|
||||
rerender(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={null} />);
|
||||
expect(paneSize(container, 'workspace-ephemeral')).toBeNull();
|
||||
expect(paneSize(container, 'workspace-base')).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pane that asks for a different split', () => {
|
||||
test('defaultBaseSize is the WORKSPACE share and the pane takes the remainder', () => {
|
||||
const { container } = render(
|
||||
<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={{ ...viewer, defaultBaseSize: 25 }} />,
|
||||
);
|
||||
expect(paneSize(container, 'workspace-base')).toBe(25);
|
||||
expect(paneSize(container, 'workspace-ephemeral')).toBe(75);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: background-jobs/a-spawned-agent-is-invisible-in-the-interface
|
||||
title: 'An agent spawned by a background job is invisible in the interface'
|
||||
theme: background-jobs
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 1
|
||||
---
|
||||
|
||||
**An agent spawned by a background job is invisible in the interface, even though it is fully readable.**
|
||||
|
||||
A watcher loop that fires spawns `claude -p`, and that child writes an ordinary transcript under the
|
||||
project directory for its cwd. So it opens in `/chat/<its-id>` like any other session — verified on
|
||||
2026-08-21 while the deploy was mid-flight: fourteen messages, the right cwd, rendered live in the normal
|
||||
chat view with no work needed.
|
||||
|
||||
Nothing in the UI tells you that id exists. The parent's job panel shows the child's `stream-json` piped
|
||||
through `jq`, which is genuinely good — you see its tool calls as they happen — but it is a text stream in
|
||||
a small box, and there is no link. You can only find the session by knowing to look for it.
|
||||
|
||||
Two fixes, and the small one is most of the value:
|
||||
|
||||
- **Have the loop print the transcript id as its first line.** One `echo`, no platform change, and the id
|
||||
becomes clickable-by-copy in the panel the owner is already watching.
|
||||
- **A real row for it.** The child is a genuine session with a genuine cost; Command HQ could file it
|
||||
against the commander that spawned it, which would also fix cost attribution — today the child's tokens
|
||||
land nowhere, so a commander's cost line reads `$0.28` for a night that spent several dollars.
|
||||
|
||||
The second one needs a decision about what the child *is*. A commander is defined by its directory
|
||||
(`isCommandersDir`), and the child runs in the platform checkout, so it does not qualify — deliberately.
|
||||
Making it visible probably means a different relationship than "another commander".
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: fleet/a-deploy-report-verifies-pm2-not-that-the-app
|
||||
title: 'A deploy verifies PM2, not that the app still works'
|
||||
theme: fleet
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 4
|
||||
---
|
||||
|
||||
**A deploy verifies PM2, not that the app still works.** The deploy commander raised this against itself,
|
||||
unprompted, in its own report: _"Nothing was verified beyond `pm2`."_ That is the most valuable line in
|
||||
either report so far and it should not be true twice more.
|
||||
|
||||
What step 6 checks today is that the process it restarted is `online` and that `restart_time` moved. That
|
||||
catches a crash on boot. It does not catch a server that starts, listens, and serves a 500 — or one that
|
||||
serves a stale bundle, which is the exact failure that cost an hour on 2026-08-21 when a rebuilt frontend
|
||||
was never picked up because the running process still held the previous `index.gen.html`.
|
||||
|
||||
The check that would have caught all of it is one line and needs nothing built:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:<port>/
|
||||
```
|
||||
|
||||
Two things to settle rather than assume. **The port differs per host** — take it from that host's `.env`
|
||||
(`OFFICERDEV_PORT`), not from a constant; `officer-dev` is 9000 and nothing guarantees the others are.
|
||||
And **a 200 on `/` is a low bar**: it proves the SPA shell is served. An endpoint that touches Postgres
|
||||
would prove more, and choosing one is the actual design question here — probably `/api/health` if it
|
||||
exists, and if it does not, that is the smaller item to do first.
|
||||
|
||||
Worth adding to the plan format too, so a release can name its own smoke test rather than relying on a
|
||||
generic one.
|
||||
@@ -2,7 +2,7 @@
|
||||
id: fleet/a-way-to-mark-a-commit-this-one-ships-so-a
|
||||
title: "A way to mark a commit \"this one ships\", so a release is not just `master`'s tip"
|
||||
theme: fleet
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 2
|
||||
@@ -29,3 +29,22 @@ Shape to settle when it is built:
|
||||
be a chosen one.
|
||||
- The updater then does a checkout at that SHA, not a `pull` — same discipline the plugin-pinning
|
||||
item asks for: **verify `rev-parse HEAD` matches, or refuse.**
|
||||
|
||||
---
|
||||
|
||||
**Done 2026-08-21 — as a tag, which is what this item ruled out.** The owner reversed it on being shown
|
||||
the trade: "we don't even need a code word if we tag with deploy/date and the version". `deploy/<version>`,
|
||||
e.g. `deploy/2026.08.21.02`.
|
||||
|
||||
The argument that changed it was not about refs at all. A codeword **can be written by accident**: every
|
||||
document about deploying contains it, this file included, and so does every conversation planning one — so
|
||||
the trigger needs 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 also names its own version, which a codeword
|
||||
does not, and `git ls-remote --tags` answers "is there one" without fetching.
|
||||
|
||||
The cost this item correctly identified is real and was accepted: a tag is a second ref to mint and push.
|
||||
It is two commands in the release recipe.
|
||||
|
||||
What replaced the "un-marking is forward-only" worry: the deploy is idempotent by a **local** record, not
|
||||
by the ref. `scripts/deploy-claim.sh` takes one claim per version and never gives it back to a machine, so
|
||||
a tag being moved or deleted cannot cause a second rollout.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: fleet/an-orchestrator-that-acts-on-every-server-at
|
||||
title: "An orchestrator that acts on every server at once"
|
||||
theme: fleet
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-20
|
||||
date_source: assumed
|
||||
order: 1
|
||||
@@ -23,3 +23,24 @@ Open: whether this is a script over SSH, an Officer screen, or a chat capability
|
||||
`headscale` and `omega` — checkout present, nothing running — are targets at all. Note the
|
||||
platform already has an **Update button** for a single server (`03abdeb2`); this is that, fanned
|
||||
out, and it should probably share the code rather than reimplement the pull.
|
||||
|
||||
---
|
||||
|
||||
**Done 2026-08-21, and not as any of the three shapes this item proposed.** It is not a script, not a
|
||||
screen and not a chat capability — it is an **agent** given a written procedure
|
||||
(`prompts/deploy-commander.md`) and a list it has to read rather than a list baked into it. Tonight it
|
||||
rolled `2026.08.21.02` and `2026.08.21.03` across six hosts unattended and wrote its own reports.
|
||||
|
||||
All three verified facts in this item survived contact and all three earned their place: it takes each
|
||||
checkout from PM2's `pm_cwd`, so `alpha`'s stale second checkout was never touched; it pulls in place
|
||||
rather than assuming a remote; and it takes the login user per host, so `deadpool` was reached as `br`.
|
||||
|
||||
Two things this item did not anticipate, both now in the procedure. It must **skip its own host entirely**
|
||||
— restarting `officer` there kills the rollout mid-flight — which leaves that one machine needing a
|
||||
deliberate restart afterwards (`todo/fleet/the-deploy-commander-cannot-finish-its-own-host`). And a
|
||||
release needs **one attempt only**: the first watcher retried a failed deploy every two minutes, so that
|
||||
rule is now `scripts/deploy-claim.sh` rather than a paragraph anyone re-implements.
|
||||
|
||||
It does **not** share code with the single-server Update button. Worth revisiting, but the overlap turned
|
||||
out to be smaller than expected: the button restarts a process, and almost all of this is the reading,
|
||||
skipping and reporting around that.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: fleet/plugins-update-restarts-every-sidecar-so-the
|
||||
title: '`plugins:update` restarts every sidecar, so the report understates what moved'
|
||||
theme: fleet
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 7
|
||||
---
|
||||
|
||||
**`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin. On `alpha`
|
||||
that is twelve processes bouncing during step 3 — before the plan's own restart step, and not something
|
||||
any plan asked for.
|
||||
|
||||
Nothing is broken and this is not new behaviour. The problem is honesty: a deploy report whose "restarted"
|
||||
column reads `officer` is describing one restart when thirteen happened, and the whole value of these
|
||||
reports is that they can be trusted about what moved.
|
||||
|
||||
Two ways out, and they are not the same decision:
|
||||
|
||||
- **Make the report accurate** — the plan format grows a note, or the commander reads `restart_time` for
|
||||
every process rather than only the ones it restarted. Cheap, and it is the honest minimum.
|
||||
- **Make the script not do it** — a `--no-restart` flag, with the deploy restarting what the plan names.
|
||||
That is the better shape (a plan that says "restart `officer` only" should mean it) but it is a change
|
||||
to something that works, and a plugin whose sidecar is not restarted after an update may well be broken
|
||||
in a way nobody notices until later.
|
||||
|
||||
Do the first regardless. The second is a real design call.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: fleet/the-deploy-commander-cannot-finish-its-own-host
|
||||
title: 'The deploy commander cannot finish its own host'
|
||||
theme: fleet
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 5
|
||||
---
|
||||
|
||||
**The deploy commander cannot finish its own host, so one machine is always a build behind until somebody
|
||||
restarts it by hand.** After `2026.08.21.03` that skip was two releases deep on `officer-dev` —
|
||||
`restart_time` unchanged at 155 — with the code for both sitting on disk.
|
||||
|
||||
The skip itself is correct and should not be removed. The commander is a session inside
|
||||
`officer-claude-code` on that machine; restarting `officer` there ends the rollout mid-flight, and it is
|
||||
not hypothetical — a manual deploy on 2026-08-21 had to leave that one sidecar alone to survive writing
|
||||
its own report. Restarting only `officer` and leaving the sidecar would technically work and is worse: it
|
||||
puts one host in a state nobody chose, a step behind the rest, discovered later.
|
||||
|
||||
So the question is what finishes it, and there are three honest answers:
|
||||
|
||||
- **A deferred restart the platform runs after the session ends.** Cheapest. The commander writes a
|
||||
one-shot request, something outside the agent's process honours it, and the report says a restart is
|
||||
pending rather than outstanding. Needs a thing that is not the agent and not `officer` — `at`, a systemd
|
||||
transient unit, or PM2 itself.
|
||||
- **A second machine deploys this one.** Cleanest and needs no new mechanism at all: any other host's
|
||||
commander has no reason to skip `officer-dev`. Costs a second armed watcher and raises "who deploys the
|
||||
deployer" — two watchers must not both claim a release, which `scripts/deploy-claim.sh` already prevents
|
||||
per host but **not across hosts**, since the claim is local by design.
|
||||
- **Leave it manual and make it loud.** The report already says "outstanding". A notification would close
|
||||
most of the gap for none of the work.
|
||||
|
||||
Not a decision to make from the code — it is the owner's call which of those three shapes he wants.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: fleet/the-watcher-loop-is-rewritten-by-an-agent-every
|
||||
title: 'The watcher loop is rewritten by an agent every time it arms'
|
||||
theme: fleet
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 6
|
||||
---
|
||||
|
||||
**The watcher loop is rewritten from scratch by an agent every time one arms**, so its correctness is
|
||||
re-rolled on every arming. The first one on 2026-08-21 was careful, logged honestly, set a `timeout` on
|
||||
its network call, routed stderr away from the JSON it was parsing — and still contained an unbounded
|
||||
retry, because the obvious way to ask "have I done this release" is to test the `.done` file, and that
|
||||
file only exists on **success**.
|
||||
|
||||
`scripts/deploy-claim.sh` fixed that one instance by moving the rule into code. The general problem is
|
||||
untouched: the next loop can get something else subtly wrong, and nothing will notice until it costs
|
||||
money or misses a release.
|
||||
|
||||
Things a platform-owned loop would make constant rather than re-derived, all of them learned by an agent
|
||||
getting them wrong once:
|
||||
|
||||
- Claim the version before spawning anything.
|
||||
- `OFFICER_ROOT` set explicitly — it is **empty** in the sidecar's environment, so the lock path resolves
|
||||
to `/data/deploys` and every release looks permanently undeployed.
|
||||
- stderr kept out of the `stream-json` stream, or the `claude.ai connectors are disabled` warning corrupts
|
||||
the first `jq` parse.
|
||||
- `jq --unbuffered`, or the job panel shows nothing for twenty minutes and reads as hung.
|
||||
|
||||
The tension worth naming: an agent writing its own loop is also the reason this worked at all on the first
|
||||
night, from a prose description, with no watcher feature built. A fixed script is more correct and less
|
||||
general. The likely answer is a **script the loop calls** rather than a script that replaces it — which is
|
||||
what the claim rule already is, and it could absorb the four items above the same way.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: known-bugs/a-commander-opened-from-chat-loses-its-context
|
||||
title: 'A commander opened from /chat loses its commander controls'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 4
|
||||
---
|
||||
|
||||
**A commander opened from `/chat` reports the ordinary chat context, so the composer hides every control
|
||||
that only exists for a commander.** The visible symptom is the background-job dialog offering "Stop when
|
||||
it fires" and "Keep going" but not **"Exit and hand back to the agent, then re-arm"** — the one a watcher
|
||||
actually wants, and the dialog's own default inside a commander.
|
||||
|
||||
Introduced 2026-08-21 by a deliberate UX change: a commander in the Live list opens in `/chat` rather than
|
||||
navigating away to Command HQ, because being thrown out of the page you are reading costs more than the
|
||||
metadata you did not ask for. That change is right and should stay.
|
||||
|
||||
The defect is that `isCommander` is derived from **the screen** rather than from **the session**:
|
||||
`isCommander={manager.context === 'command-hq'}`, and `/chat` passes no such context. A commander is a
|
||||
commander wherever it is rendered.
|
||||
|
||||
The fix is to carry it on the session. `ClaudeSessionSummary` and `/chat/live` both already have
|
||||
`isCommander`, derived server-side from the cwd; `GET /chat/sessions/:id` does not, and that is the one the
|
||||
chat detail reads. Adding it there and letting the composer take it from the session rather than the route
|
||||
closes this and makes the same thing work in any future surface that renders a chat.
|
||||
|
||||
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
|
||||
option and a component prop, neither reading the other's — which is what hid the original bug twice.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: known-bugs/askuserquestion-can-fail-to-parse-its-own-call
|
||||
title: 'AskUserQuestion can fail to parse its own call'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**`AskUserQuestion` rejected a well-formed call with `InputValidationError: called with input that could
|
||||
not be parsed as JSON`.** Seen 2026-08-21 in the deploy commander's first turn, on Sonnet.
|
||||
|
||||
The tell is in the transcript: the failed call arrives as `{"__unparsedToolInput": {"raw": "{\"questions\":
|
||||
[…"}}` — the arguments reached the harness as a **string** rather than an object, and the harness reported
|
||||
that as the model's fault. The retry, byte-identical in intent, went through and the question rendered
|
||||
normally.
|
||||
|
||||
So it is recoverable and it self-corrected, which is why it is filed here rather than treated as urgent.
|
||||
What makes it worth fixing anyway: this is the same tool as the stall bug fixed earlier the same day, on
|
||||
the path where an agent asks a question and waits. A parse failure there burns a turn, and if it happened
|
||||
on the **second** attempt too the agent would most likely give up asking and guess — which is the failure
|
||||
mode the question mechanism exists to prevent.
|
||||
|
||||
Worth capturing the raw payload when this fires rather than only the first 200 bytes, since the message
|
||||
truncates exactly where the interesting part would be.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: mobile-apps/the-live-chat-swipe-order-should-exclude
|
||||
title: 'The live-chat swipe order should exclude commanders'
|
||||
theme: mobile-apps
|
||||
status: open
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**The iPhone app swipes left and right between live chats, and commanders should not be in that order.**
|
||||
|
||||
The reason it needs the server's help is that the phone has nowhere to put a tab. The web app solved the
|
||||
same problem on 2026-08-21 with two tabs inside Live — `Agents (n)` / `Commanders (n)` — and a phone
|
||||
swiping through a flat sequence cannot do that, so it can only filter if the payload says which rows are
|
||||
commanders.
|
||||
|
||||
**The server side is done and shipped in `2026.08.21.02`.** `GET /chat/live` rows now carry
|
||||
`isCommander: boolean`, derived server-side from the session's cwd rather than from a database lookup:
|
||||
every commander shares one directory, so the directory is the answer, it costs no query, and it is already
|
||||
correct during a commander's first turn before any row exists. `GET /chat/sessions` carries it too.
|
||||
|
||||
So the app work is: read the field, drop `isCommander` rows from the swipe sequence, and decide whether
|
||||
they are reachable at all on mobile or simply absent. A deploy commander appearing between two
|
||||
conversations you are having is the thing to prevent.
|
||||
|
||||
**Owed: a short document for the mobile developer** — the field, where it comes from, and the one caveat
|
||||
worth stating, which is that a row is `isCommander` from its first turn even before it has a title. The
|
||||
owner asked for this on 2026-08-21 and said explicitly it should be written against a shipped version
|
||||
rather than an intended one; that version is `2026.08.21.02`.
|
||||
@@ -22,3 +22,17 @@ Four bugs came out of that work and are fixed: closing a pane sent you home; `?v
|
||||
every page load by a mount effect that had made deep links impossible since 2026-02-23; navigating kept a
|
||||
pane belonging to the folder you left; and the breadcrumb opted out of that last rule because it is a
|
||||
`<Link>` and never called the function enforcing it.
|
||||
|
||||
**A fifth, fixed 2026-08-21.** The URL survived a refresh and the pane still did not open — reported as
|
||||
"refresh loses the panel, and then the file won't open". Nothing to do with the query string this time:
|
||||
`WorkspaceView` gave the ephemeral pane `defaultSize={0}` and opened it from an effect calling
|
||||
`panel.resize()`, so open/closed lived in two places. On a deep link the effect ran before
|
||||
`workspace.isLoaded` was true — when the early return means nothing is rendered and the ref is null — and
|
||||
its deps could not change afterwards, so it never ran again. The second half is what made it look like a
|
||||
click bug: with `?view=` already naming the file, double-clicking it changed no state, so nothing re-ran.
|
||||
The pane is now rendered conditionally with `id` + `order` and sized declaratively; `ephemeral-pane.test.tsx`
|
||||
asserts the SIZE rather than the element's presence, because a pane at zero width passes any
|
||||
"is it in the document" check. Five of its six tests fail against the old implementation.
|
||||
|
||||
The shape is worth keeping in mind for the rest of this theme: **state that the URL owns, mirrored into an
|
||||
imperative correction, will eventually be read at a moment the correction cannot run.**
|
||||
|
||||
Reference in New Issue
Block a user