Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee0b89bd7 | ||
|
|
aa35428dde | ||
|
|
854f957058 | ||
|
|
edeb434233 | ||
|
|
8b4a5b3b0b | ||
|
|
730aeab6c5 | ||
|
|
2651c45733 | ||
|
|
91bc4ac74f | ||
|
|
a51a969cd0 | ||
|
|
5c3ffa7a06 | ||
|
|
1155449e4b | ||
|
|
a2ddf258b6 | ||
|
|
5c802b07b5 | ||
|
|
383df51a99 | ||
|
|
3551a3af71 | ||
|
|
038251f9c9 | ||
|
|
4f590f8879 | ||
|
|
c04fa15eb5 | ||
|
|
2aef23c8ba | ||
|
|
eefcb06e35 | ||
|
|
8ac3ca7fe5 | ||
|
|
03e7ca39c3 | ||
|
|
3254925506 | ||
|
|
a3fd3ade01 | ||
|
|
3837af657d | ||
|
|
749c80c877 | ||
|
|
af918251d3 | ||
|
|
ae27478a27 | ||
|
|
0bfad0e592 | ||
|
|
aad382f4be | ||
|
|
fc8ce04f2b | ||
|
|
cb51f35cad | ||
|
|
a6e7f64ef2 | ||
|
|
f065e2c0f8 | ||
|
|
1c47d0bba7 | ||
|
|
2bd8701b15 | ||
|
|
d08ae41f1f | ||
|
|
a356656a60 | ||
|
|
1c7619e184 | ||
|
|
3f1ba38f19 | ||
|
|
577ebde902 | ||
|
|
59f600d34d | ||
|
|
cbedfed2ba | ||
|
|
f9a3699603 | ||
|
|
75affec091 | ||
|
|
de9d229cfa | ||
|
|
cabc4825ef | ||
|
|
87ff6ed326 | ||
|
|
6d6e8a841e | ||
|
|
b7112f6e5d | ||
|
|
ee9106ef54 | ||
|
|
e4f1b75f9b | ||
|
|
4a206086af | ||
|
|
29d998b6c3 | ||
|
|
228f2f4638 | ||
|
|
68d60e409b | ||
|
|
b1dfa3f7bb | ||
|
|
f586d748d6 | ||
|
|
bcdc769083 | ||
|
|
430f058a6d | ||
|
|
9b459b79dd | ||
|
|
ab77c66f04 | ||
|
|
dd6135cac1 | ||
|
|
bd15a186b5 | ||
|
|
a15fadbddc | ||
|
|
7e054ddf11 | ||
|
|
0c2ff88c88 | ||
|
|
c982353381 |
@@ -113,6 +113,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
||||
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
||||
first. The source build also needs `build-essential` and `python3`.
|
||||
|
||||
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
|
||||
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
|
||||
watched. Two places carry that number and they move together: `BUN_SERIES` in
|
||||
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
|
||||
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
|
||||
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
|
||||
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
|
||||
|
||||
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
|
||||
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
|
||||
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
|
||||
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
|
||||
|
||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
||||
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||
- **Backend**: Hono
|
||||
@@ -194,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). 27 entries as of 2026-08-13.
|
||||
never grantable at any level). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
@@ -224,9 +237,20 @@ bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||
place to drift, and it drifts in the same edit that misses the doc.
|
||||
|
||||
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||
written had been wrong for over a week.
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
|
||||
@@ -20,39 +20,39 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**87 open, 22 closed**, across 29 themes.
|
||||
**90 open, 25 closed**, across 29 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/) | 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/) | 18 | 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 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 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/) | 3 | 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/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||
| [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 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 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/) | 3 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
|
||||
## Records — no items, kept for the reasoning
|
||||
|
||||
@@ -63,13 +63,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
|
||||
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
|
||||
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
|
||||
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
|
||||
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
|
||||
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
|
||||
- `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)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# 2026.08.22.01 — deploy report
|
||||
|
||||
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
|
||||
No host failed, no process came back `errored`.
|
||||
|
||||
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
|
||||
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
|
||||
|
||||
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
|
||||
`~/dockers/officer.dev/platform` was not touched).
|
||||
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
|
||||
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
|
||||
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||
photos, soulseek, transmission, vault.
|
||||
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
|
||||
- All 19 processes `online` afterwards.
|
||||
|
||||
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
|
||||
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
|
||||
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
|
||||
captured an explicit `0`.
|
||||
|
||||
### `fofos` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
|
||||
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
|
||||
touch it, and it was left alone. No tracked change.
|
||||
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
|
||||
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
|
||||
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
|
||||
fix of 2026-08-21 is holding.
|
||||
|
||||
### `macbookpro` — succeeded
|
||||
|
||||
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
|
||||
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
|
||||
interactive shell, exactly as the plan and `docs/servers.md` require.
|
||||
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
|
||||
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
|
||||
|
||||
### `edge-pertento-bridge` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
|
||||
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
|
||||
|
||||
### `production-pertento-bridge` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
|
||||
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
|
||||
and the command still exited 0 — recorded so the next reader does not chase it.
|
||||
- `pm2 restart officer`: 9 → 10, `online`.
|
||||
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
|
||||
All `online`, none touched.
|
||||
|
||||
### `deadpool-rezio-bridge` — succeeded
|
||||
|
||||
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
|
||||
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
|
||||
the others'. Not an error.
|
||||
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
|
||||
|
||||
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
|
||||
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
|
||||
another agent working in this same tree during the rollout. Its work was left alone; only this report was
|
||||
staged, by path.
|
||||
|
||||
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
|
||||
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
|
||||
at `78db9c8c`, the tag — but the next release will include them.
|
||||
|
||||
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
|
||||
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
|
||||
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
|
||||
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
|
||||
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
|
||||
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
|
||||
happy with the rest. It needs no pull.
|
||||
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
|
||||
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
|
||||
files arrived, not that each parses.
|
||||
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026.08.22.02
|
||||
|
||||
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
|
||||
authorization resolver, and the email plugin's multi-account backend.
|
||||
|
||||
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
|
||||
dropdown fails on a missing relation the first time it reads a starred list.
|
||||
|
||||
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
|
||||
`bun.lock`. Do not run `bun install`.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
|
||||
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
|
||||
sorting it to the top, so neighbours survive.
|
||||
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
|
||||
the owner's `vimazing`.
|
||||
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
|
||||
Brave claims it at the browser level and the key never reaches the page.
|
||||
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
|
||||
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
|
||||
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
|
||||
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
|
||||
from the database calls. No behaviour change.
|
||||
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
|
||||
declaration, instead of a parallel list of handlers.
|
||||
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
|
||||
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git pull --ff-only`
|
||||
2. **`bun db:push`** — new this release
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||
report rather than being omitted.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||
`docs/servers.md`.
|
||||
|
||||
## `officer-dev` — skip it, and it is already current
|
||||
|
||||
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
|
||||
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
|
||||
|
||||
## Then check
|
||||
|
||||
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
|
||||
|
||||
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
|
||||
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
|
||||
say where it is not.
|
||||
|
||||
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
|
||||
@@ -0,0 +1,105 @@
|
||||
# 2026.08.22.02 — deploy report
|
||||
|
||||
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
|
||||
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
|
||||
|
||||
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
|
||||
`package.json` `2026.08.22.02`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
|
||||
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
|
||||
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
|
||||
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
|
||||
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
|
||||
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
|
||||
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
|
||||
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
|
||||
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
|
||||
|
||||
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
|
||||
|
||||
## Per host
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
|
||||
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
|
||||
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
|
||||
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
|
||||
|
||||
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
|
||||
the tree, the schema and the running process are all already on this release.
|
||||
|
||||
### `alpha` — done
|
||||
|
||||
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
|
||||
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
|
||||
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
|
||||
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
|
||||
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
|
||||
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
|
||||
|
||||
### `fofos` — done
|
||||
|
||||
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
|
||||
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
|
||||
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
|
||||
all 8 processes `online`. `theme_favorites` present.
|
||||
|
||||
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
|
||||
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
|
||||
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
|
||||
use `POSTGRES_URL`.
|
||||
|
||||
### `macbookpro` — done, awake
|
||||
|
||||
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
|
||||
like the rest. Pulled straight to `68d60e40`.
|
||||
|
||||
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
|
||||
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
|
||||
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
|
||||
|
||||
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
|
||||
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
|
||||
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
|
||||
|
||||
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
|
||||
unrelated to this release, but it appears in the raw output above every command.
|
||||
|
||||
### `edge-pertento-bridge` — done
|
||||
|
||||
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
|
||||
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
|
||||
processes `online`. `theme_favorites` present.
|
||||
|
||||
### `production-pertento-bridge` — done
|
||||
|
||||
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
|
||||
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
|
||||
`theme_favorites` present.
|
||||
|
||||
### `deadpool-rezio-bridge` — done
|
||||
|
||||
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
|
||||
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
|
||||
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
|
||||
|
||||
## What did not happen
|
||||
|
||||
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
|
||||
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
|
||||
what this agent executes.
|
||||
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
|
||||
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
|
||||
here rather than reporting only `officer` is the plan's explicit instruction.
|
||||
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
|
||||
Everything else was verified exactly as written.
|
||||
|
||||
## Claim record
|
||||
|
||||
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
|
||||
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
|
||||
cannot cause a second rollout.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 2026.08.23.01
|
||||
|
||||
The commander creation flow, rebuilt end to end — named, given a directory, and started by a button that
|
||||
makes it interview you rather than waiting for a prompt somebody else wrote. Plus the delete path that
|
||||
actually removes one, `/minesweeper`, and `docs:check` guarding the documentation's checkable claims.
|
||||
|
||||
**SCHEMA CHANGE.** `bun db:push` is a step. `command_hq_spawned` is new and `command_hq_sessions` gains
|
||||
`name`. Without the push, Command HQ fails on a missing column the first time it lists a commander.
|
||||
|
||||
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.22.02..HEAD`. `package.json`
|
||||
moved only to add `engines.bun` and two scripts. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **A commander is created, not configured.** Name it, choose its working directory, press Start. It reads
|
||||
the directory, works out whether the job is already written down there, and either reads its
|
||||
understanding back for confirmation or asks what it should be watching. The name is the brief — it is the
|
||||
only thing the owner says before anything else, and it now reaches the model instead of only titling the
|
||||
transcript.
|
||||
- **`command_hq_sessions.name`** — the platform owns the name. It was only ever in the transcript's summary
|
||||
record, which the harness cannot write until the first turn ends, so the list showed the opening brief
|
||||
while the header showed the real name.
|
||||
- **Deleting a commander removes it.** Four defects, each of which left something behind: the session kill
|
||||
ran in the wrong process and always failed; the transcript was deleted while the harness was still
|
||||
writing it, so it came straight back as an untitled chat; `chat_session_events` is keyed on officer's
|
||||
session key while everything else uses the transcript id, so its rows were never matched; and the key was
|
||||
resolved from an in-memory map that is empty for anything older than the process. The event rows carry
|
||||
both ids, so they are asked when the maps cannot answer.
|
||||
- **`GET /chat/live?kind=`** — `agent` (the default), `commander`, or `all`, with `counts` always
|
||||
describing the whole set. Defaulting to agents means a client that forgets cannot be handed a commander;
|
||||
the web list asks for `all` explicitly.
|
||||
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
|
||||
error — so answering a question during the reconnect window after a restart vanished and the card span
|
||||
forever. Question answers and plan decisions now report it.
|
||||
- **`/minesweeper`** — a wine session streamed to the browser over RFB. The session is not running on
|
||||
`officer-dev`, so the route renders its "not running" state with a Start button. That is expected.
|
||||
- **Usage limits are read live.** `limitsCache` was keyed on nothing, so switching Anthropic account showed
|
||||
the previous account's percentages under the new name. The cache remains for the failure path only, keyed
|
||||
per account.
|
||||
- **`bun docs:check`** compares documented claims against values derived at run time — the permission
|
||||
count, the PM2 entry count, the generated platform skill. It found both claims it was written to check
|
||||
already wrong. **Required before tagging a release.**
|
||||
- **Empty transcripts no longer list.** A file holding only harness metadata and no turns is not a
|
||||
conversation; four had accumulated as "(untitled) · 0 msgs".
|
||||
- **Bun is held at the 1.3 series** — `engines.bun` and `BUN_SERIES` move together. Do not run
|
||||
`bun upgrade` on a host.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git pull --ff-only`
|
||||
2. **`bun db:push`** — new this release
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||
the report rather than being omitted.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||
`docs/servers.md`.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
This is the machine the rollout runs from, and restarting `officer` here ends the agent performing it.
|
||||
Name it as skipped in the report. It is already current.
|
||||
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
|
||||
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
|
||||
still null contributes to no directory (see above).
|
||||
|
||||
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
|
||||
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
|
||||
accepted because the web client's own type is already `'agents' | 'commanders'`.
|
||||
|
||||
The default is the point. Commanders run background work and the phone swipes between live chats one at a
|
||||
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
|
||||
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
|
||||
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
|
||||
built to show both, and they are the ones positioned to notice if the answer is wrong.
|
||||
|
||||
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
|
||||
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
|
||||
costing more round trips than the client-side one it replaced.
|
||||
|
||||
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
|
||||
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
|
||||
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
|
||||
about which of its rows is one.
|
||||
|
||||
---
|
||||
|
||||
## Landmines
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# SendOff — files land in the user's own tree, and a push says where
|
||||
|
||||
**From:** mobile · **Date:** 2026-08-23 · **v2, replacing this morning's spec entirely.** The v1
|
||||
design (staging area, transfer/target/file tables, ack lifecycle, TTL cleanup) is withdrawn — Andre's
|
||||
observation kills it: every user already has a filesystem on the server, a file browser on the web,
|
||||
and a Files app on the phone. SendOff is not a new storage system; it is a folder convention, one
|
||||
endpoint, and a push.
|
||||
|
||||
## The convention
|
||||
|
||||
```
|
||||
~/Downloads/sendoff/from-<username>/ ← a file another USER sent you
|
||||
~/Downloads/sendoff/<machine>/ ← a file you sent yourself, aimed at a named device
|
||||
```
|
||||
|
||||
That is the whole data model. Nothing to ack, nothing to expire, nothing to clean up — the files are
|
||||
ordinary files in the recipient's tree, visible in every file UI that already exists, managed like
|
||||
anything else they own.
|
||||
|
||||
## The endpoint
|
||||
|
||||
```
|
||||
POST /api/sendoff/send
|
||||
multipart: any number of `file` parts + a `meta` part:
|
||||
{ "toUser": "<username>" } — cross-user: writes into THEIR tree, from-<sender>/
|
||||
{ "machine": "<device name>" } — self: writes into own tree, <machine>/
|
||||
→ { path: "Downloads/sendoff/…", files: [{ name, size }] }
|
||||
```
|
||||
|
||||
Server-side because of the cross-user case: `/api/file-browser/upload` correctly writes only the
|
||||
caller's own tree, and that must not change. This endpoint is the one sanctioned door into another
|
||||
user's filesystem, and it writes ONLY under `Downloads/sendoff/from-<sender>/` — the sender's
|
||||
identity comes from their token, never from the payload, so the folder name cannot lie about who
|
||||
sent it. Filenames sanitised, collisions suffixed `(1)` — never overwritten; a second send of the
|
||||
same name is a second file.
|
||||
|
||||
Whether user A may send to user B at all is a platform policy decision (everyone on the server? a
|
||||
grant?) — flagging it rather than deciding it here.
|
||||
|
||||
## The push
|
||||
|
||||
On write, push to the recipient's devices through the existing pipeline:
|
||||
|
||||
```
|
||||
{ "officer": { "c": "officer/core:sendoff.landed", "path": "Downloads/sendoff/from-andre" } }
|
||||
alert: "photo.jpg from andre" / "3 files from omega"
|
||||
```
|
||||
|
||||
The push catalogue's route for the category:
|
||||
|
||||
```
|
||||
officer://files?path={path}
|
||||
```
|
||||
|
||||
Mobile opens routes from the catalogue verbatim (`openNotificationRoute`), and Officer now parses
|
||||
`officer://files?path=…` into the Files app at that folder. The web can deep-link its file browser
|
||||
the same way.
|
||||
|
||||
For a `machine`-targeted send, push only to that machine's device if it can be identified; to all
|
||||
the user's devices otherwise — the path in the folder name already says who it was for.
|
||||
|
||||
## Devices
|
||||
|
||||
The `machine` name needs a picker on the sending side, so a minimal registry survives from v1:
|
||||
|
||||
```
|
||||
devices: id (client uuid), user_id, name, platform, last_seen_at
|
||||
POST /api/devices/register { id, name, platform } (upsert; called on every app start)
|
||||
GET /api/devices → { devices } (the "send to my…" picker)
|
||||
PATCH /api/devices/:id { name } · DELETE /api/devices/:id
|
||||
```
|
||||
|
||||
No link to transfers — nothing references a device row except the picker and, best-effort, push
|
||||
targeting.
|
||||
|
||||
## Web UI
|
||||
|
||||
One send form: files + target (one of my machines / another user), POST, done. Received files need
|
||||
no UI at all — they are in the file browser already.
|
||||
|
||||
## What mobile already does (shipped against this)
|
||||
|
||||
- Registers the device on start (silent failure until the endpoint exists).
|
||||
- Parses `officer://files?path=…` and opens the Files app at that path — this is the piece that
|
||||
makes the push tap land somewhere.
|
||||
- SendOff screen: the device's name (the folder senders will aim at), and an explanation of where
|
||||
arrivals land. Sending FROM the phone comes later, likely through the existing share sheet.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Receiving in the browser (already exists — it is the file browser); cross-user permission model
|
||||
(platform decision); phone-side arbitrary-file sending (needs a document picker; the share sheet is
|
||||
the natural door and is a later step).
|
||||
+7
-2
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.22.01",
|
||||
"version": "2026.08.23.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"src/databases/*",
|
||||
"src/workspaces/*"
|
||||
],
|
||||
"engines": {
|
||||
"bun": "1.3.x"
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
||||
"gen:index": "bun run ./scripts/gen-index.ts",
|
||||
@@ -34,7 +37,9 @@
|
||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||
"setup": "bash scripts/install.sh",
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts"
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts",
|
||||
"docs:check": "bun run scripts/docs-check.ts",
|
||||
"skills:platform": "bun run scripts/gen-platform-skill.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -18,6 +18,21 @@ there is a release to deploy that has not been deployed.
|
||||
git ls-remote --tags origin 'refs/tags/deploy/*'
|
||||
```
|
||||
|
||||
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
|
||||
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
|
||||
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
|
||||
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
|
||||
long stretches.
|
||||
|
||||
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
|
||||
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
|
||||
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
|
||||
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
|
||||
|
||||
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
|
||||
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
|
||||
already trusted.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,6 +96,21 @@ load-bearing. A missing report is a missing record; a missing lock is a second d
|
||||
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.
|
||||
|
||||
## Before a release is tagged — not your job, but know it happened
|
||||
|
||||
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
|
||||
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
|
||||
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
|
||||
|
||||
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
|
||||
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
|
||||
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
|
||||
thirteen, listing eleven processes that do not run here while omitting six that do.
|
||||
|
||||
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
|
||||
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
|
||||
it would put a prose edit between two hosts.
|
||||
|
||||
## The release
|
||||
|
||||
The tag names the version. The commit it points at carries the rest:
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The wait half of the deploy commander: poll for a release tag, and when one appears that has not been
|
||||
# attempted, spawn a headless agent to deploy it.
|
||||
#
|
||||
# WHY THIS IS IN THE REPOSITORY AND NOT WRITTEN FRESH EACH ARMING
|
||||
#
|
||||
# prompts/deploy-commander.md says the watcher loop is rewritten from scratch every time one arms, and
|
||||
# treats that as a given. It is also how the same defect keeps arriving. The loop that was on disk at
|
||||
# data/deploy-watch.sh from 2026-08-21 gated on "$v.done" — a file that exists only on SUCCESS — and never
|
||||
# called deploy-claim.sh at all. That is precisely the retry storm the claim script was written to make
|
||||
# impossible, reintroduced in the one place the claim script cannot reach: the caller that decides whether
|
||||
# to invoke it. A rule enforced by a script nobody calls is not enforced.
|
||||
#
|
||||
# So the loop lives next to the claim it depends on, and changes with it.
|
||||
#
|
||||
# THE BASELINE IS THE CLAIM, NOT THE SUCCESS
|
||||
#
|
||||
# A watcher needs to know what counts as new. Here that is "no claim directory for this version" — a record
|
||||
# of what has been HANDLED, not of what has SUCCEEDED. The distinction is the whole point: a release that
|
||||
# failed is handled. It is looked at by a human, not retried by a loop.
|
||||
#
|
||||
# deploy-claim.sh honours a pre-existing .done with no .claim, so releases deployed before it existed are
|
||||
# already skipped and arming this today fires on none of them.
|
||||
#
|
||||
# THE CLAIM IS TAKEN HERE, BEFORE THE SPAWN
|
||||
#
|
||||
# An action that claims its own work has claimed nothing if it dies before getting that far. So the loop
|
||||
# claims, then spawns — which means the child must be TOLD the claim is already held. It is about to read
|
||||
# prompts/deploy-commander.md, whose claim instruction is addressed to whoever reads it, and an obedient
|
||||
# child that claims for itself gets exit 1 and correctly refuses to deploy. See PROMPT below.
|
||||
set -u
|
||||
|
||||
PLATFORM_DIR="${PLATFORM_DIR:-$HOME/officer-dev/platform}"
|
||||
# Empty in the sidecar's environment, where anything built from it resolves to /data/... and the record
|
||||
# lands where nothing reads it. A watcher whose baseline is invisible to itself has none.
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-$HOME/officer-dev}"
|
||||
DEPLOYS_DIR="${DEPLOYS_DIR:-$OFFICER_ROOT/data/deploys}"
|
||||
POLL_SECONDS="${POLL_SECONDS:-120}"
|
||||
export OFFICER_ROOT DEPLOYS_DIR
|
||||
|
||||
CLAIM_SH="$PLATFORM_DIR/scripts/deploy-claim.sh"
|
||||
|
||||
stamp() { date -Iseconds; }
|
||||
|
||||
if [ ! -x "$CLAIM_SH" ]; then
|
||||
echo "$(stamp) deploy-watch: no claim script at $CLAIM_SH — refusing to arm without one" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$DEPLOYS_DIR" || exit 2
|
||||
|
||||
echo "$(stamp) deploy-watch: armed. polling $(cd "$PLATFORM_DIR" && git remote get-url origin) every ${POLL_SECONDS}s"
|
||||
echo "$(stamp) deploy-watch: baseline is $DEPLOYS_DIR — a version with a claim is never fired again"
|
||||
|
||||
while true; do
|
||||
# origin is the LOCAL gitea over the tailnet. The public hostname arrives through a proxy where every
|
||||
# connection shares one source IP and SSH is refused for long stretches — which presents as an
|
||||
# access-rights error and is not one. Eighty minutes of detection latency on 2026-08-21.
|
||||
raw=$(cd "$PLATFORM_DIR" && timeout 30 git ls-remote --tags origin 'refs/tags/deploy/*' 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "$(stamp) tick: ls-remote failed (exit $rc) — ${raw%%$'\n'*}"
|
||||
sleep "$POLL_SECONDS"
|
||||
continue
|
||||
fi
|
||||
|
||||
versions=$(printf '%s\n' "$raw" | grep -v '\^{}' | awk '{print $2}' | sed -E 's#.*refs/tags/deploy/##' | sort -u)
|
||||
|
||||
pending=""
|
||||
for v in $versions; do
|
||||
[ -n "$v" ] || continue
|
||||
[ "$("$CLAIM_SH" state "$v" 2>/dev/null)" = unclaimed ] && pending="$pending $v"
|
||||
done
|
||||
|
||||
# Every tick leaves a line, including the quiet ones: a fresh timestamp is the only thing that tells
|
||||
# "nothing to deploy" apart from "the loop died".
|
||||
echo "$(stamp) tick: $(printf '%s\n' "$versions" | grep -c .) tags,${pending:- none} pending"
|
||||
|
||||
for v in $pending; do
|
||||
if ! "$CLAIM_SH" claim "$v"; then
|
||||
# Lost a race with another watcher, or it was claimed between the state check and here. Either way
|
||||
# it is not ours and never becomes ours.
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$(stamp) deploy/$v claimed — spawning deploy agent"
|
||||
|
||||
# The child reads the procedure, which tells ITS reader to claim first. It must not: we hold the claim
|
||||
# on its behalf, and its own claim would return exit 1 and stop the deploy it was spawned to do.
|
||||
PROMPT="Read prompts/deploy-commander.md in $PLATFORM_DIR and follow it exactly, for the release deploy/$v.
|
||||
|
||||
The claim for $v is ALREADY HELD on your behalf by the watcher that spawned you. Do NOT run
|
||||
'scripts/deploy-claim.sh claim $v' — it will refuse, correctly, because the claim is taken, and stopping
|
||||
there would abandon a deploy you were started to perform. Everything else in that file applies unchanged,
|
||||
including running 'scripts/deploy-claim.sh done $v' once the rollout has succeeded.
|
||||
|
||||
Roll it out across every host the plan names, skip the host you are running on, and write the report
|
||||
exactly as the procedure specifies."
|
||||
|
||||
raw_log="$DEPLOYS_DIR/$v.child.jsonl"
|
||||
|
||||
# Nothing links a spawned agent to its parent and a child transcript does not last, so the stream is
|
||||
# kept here as it arrives. stderr never enters the pipe: the CLI writes warnings there and one landing
|
||||
# in the stream corrupts the first jq parse.
|
||||
{
|
||||
cd "$PLATFORM_DIR" && claude -p "$PROMPT" \
|
||||
--dangerously-skip-permissions \
|
||||
--output-format=stream-json \
|
||||
--verbose
|
||||
} 2> >(while IFS= read -r l; do echo " ! $l"; done) \
|
||||
| tee -a "$raw_log" \
|
||||
| jq -r --unbuffered '
|
||||
if .type=="system" and .subtype=="init" then
|
||||
" · child session " + .session_id
|
||||
elif .type=="assistant" then
|
||||
(.message.content[]? |
|
||||
if .type=="tool_use" then
|
||||
" ⚙ " + .name + ": " + ((.input.description // .input.command // .input.pattern // (.input | tostring)))
|
||||
elif .type=="text" then
|
||||
" » " + .text
|
||||
else empty end)
|
||||
elif .type=="user" then
|
||||
(.message.content[]? | select(.type=="tool_result") |
|
||||
" " + (if (.content|type)=="string" then .content else (.content // [] | map(.text? // "") | join(" ")) end))
|
||||
elif .type=="result" then
|
||||
" = deploy agent " + .subtype + ", " + (.num_turns|tostring) + " turns, " + (.total_cost_usd|tostring) + " usd"
|
||||
else empty end
|
||||
' 2>/dev/null
|
||||
|
||||
if [ -f "$DEPLOYS_DIR/$v.done" ]; then
|
||||
echo "$(stamp) deploy/$v SUCCEEDED — recorded, transcript at $raw_log"
|
||||
else
|
||||
# The claim stays held. This is the loop's most important line and the one that used to say
|
||||
# "will retry next poll".
|
||||
echo "$(stamp) deploy/$v FAILED — no .done written. NOT retrying; the claim is held."
|
||||
echo "$(stamp) transcript: $raw_log"
|
||||
echo "$(stamp) to allow another attempt, deliberately: rm -rf $DEPLOYS_DIR/$v.claim"
|
||||
fi
|
||||
done
|
||||
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Glob } from 'bun';
|
||||
|
||||
/*
|
||||
Which settings sections actually exist, read from the screens that declare them.
|
||||
|
||||
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
|
||||
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
|
||||
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
|
||||
narrow enough to be obvious when it breaks.
|
||||
*/
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
|
||||
|
||||
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
|
||||
export async function declaredSettingsPaths(): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
const glob = new Glob('**/*.tsx');
|
||||
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
|
||||
if (!base) continue;
|
||||
paths.add(base);
|
||||
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings routes Spotlight offers, and which of them nothing declares.
|
||||
*
|
||||
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
|
||||
* lucide and React.
|
||||
*/
|
||||
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
|
||||
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
|
||||
const declared = await declaredSettingsPaths();
|
||||
return { offered, missing: offered.filter((p) => !declared.has(p)) };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Does the documentation still match the code?
|
||||
|
||||
── Why a script and not a habit ──
|
||||
|
||||
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
|
||||
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
|
||||
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
|
||||
|
||||
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
|
||||
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
|
||||
number is exactly the thing nobody re-derives while editing a sentence around it.
|
||||
|
||||
Real examples this was written from, both live at the time:
|
||||
|
||||
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
|
||||
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
|
||||
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
|
||||
|
||||
── The rule for adding a check ──
|
||||
|
||||
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
|
||||
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
|
||||
the same edit that misses the doc.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type Finding = { file: string; claim: string; documented: string; actual: string };
|
||||
type CheckResult = { findings: Finding[]; skipped?: string };
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const WORKSPACE = resolve(ROOT, '..');
|
||||
|
||||
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
|
||||
|
||||
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
|
||||
const WORDS: Record<string, number> = {
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
thirteen: 13,
|
||||
fourteen: 14,
|
||||
fifteen: 15,
|
||||
sixteen: 16,
|
||||
seventeen: 17,
|
||||
eighteen: 18,
|
||||
nineteen: 19,
|
||||
twenty: 20,
|
||||
'twenty-one': 21,
|
||||
'twenty-two': 22,
|
||||
'twenty-three': 23,
|
||||
'twenty-four': 24,
|
||||
'twenty-five': 25,
|
||||
};
|
||||
const asNumber = (raw: string): number | null => {
|
||||
const digits = Number(raw);
|
||||
if (Number.isFinite(digits)) return digits;
|
||||
return WORDS[raw.toLowerCase()] ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A documented count, found by a pattern with one capture group, compared to the live value.
|
||||
*
|
||||
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
|
||||
*/
|
||||
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
|
||||
const body = read(file);
|
||||
if (!body) return { findings: [], skipped: `${file} not found` };
|
||||
const findings: Finding[] = [];
|
||||
for (const match of body.matchAll(pattern)) {
|
||||
const stated = asNumber(match[1] ?? '');
|
||||
if (stated === null || stated === actual) continue;
|
||||
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
|
||||
}
|
||||
return { findings };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const all: Finding[] = [];
|
||||
const skipped: string[] = [];
|
||||
const record = (r: CheckResult) => {
|
||||
all.push(...r.findings);
|
||||
if (r.skipped) skipped.push(r.skipped);
|
||||
};
|
||||
|
||||
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
record(
|
||||
checkCount(
|
||||
join(ROOT, 'CLAUDE.md'),
|
||||
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||
PERMISSIONS.length,
|
||||
'permission registry entries',
|
||||
),
|
||||
);
|
||||
|
||||
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
|
||||
// admission that it needs a check rather than a caveat.
|
||||
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
if (pm2.exitCode === 0) {
|
||||
try {
|
||||
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
|
||||
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
|
||||
record(
|
||||
checkCount(
|
||||
join(WORKSPACE, 'CLAUDE.md'),
|
||||
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
|
||||
officer,
|
||||
'officer PM2 entries',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
skipped.push('pm2 jlist did not parse');
|
||||
}
|
||||
} else {
|
||||
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
|
||||
skipped.push('pm2 unavailable — PM2 entry count unchecked');
|
||||
}
|
||||
|
||||
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
|
||||
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
|
||||
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
|
||||
const declared = await SPOTLIGHT_SETTINGS_PATHS();
|
||||
for (const bad of declared.missing) {
|
||||
all.push({
|
||||
file: 'Spotlight/useSpotlightItems.ts',
|
||||
claim: 'settings route offered by Spotlight',
|
||||
documented: bad,
|
||||
actual: 'no screen declares that section',
|
||||
});
|
||||
}
|
||||
|
||||
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
|
||||
// an agent quotes it to a user with no way to tell it is out of date.
|
||||
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
|
||||
const expected = await renderPlatformSkill();
|
||||
const onDisk = read(SKILL_PATH);
|
||||
if (onDisk === null) {
|
||||
all.push({
|
||||
file: 'capabilities/skills/platform/SKILL.md',
|
||||
claim: 'generated platform skill',
|
||||
documented: 'missing',
|
||||
actual: 'run `bun run skills:platform`',
|
||||
});
|
||||
} else if (onDisk !== expected) {
|
||||
all.push({
|
||||
file: 'capabilities/skills/platform/SKILL.md',
|
||||
claim: 'generated platform skill is stale',
|
||||
documented: `${onDisk.split('\n').length} lines on disk`,
|
||||
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
|
||||
});
|
||||
}
|
||||
|
||||
for (const note of skipped) console.log(` skipped: ${note}`);
|
||||
if (all.length === 0) {
|
||||
console.log('docs:check — every checkable claim matches the code');
|
||||
return;
|
||||
}
|
||||
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
|
||||
for (const f of all) {
|
||||
console.log(` ${f.file}`);
|
||||
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -34,6 +34,44 @@ const fail = (what: string, fix: string) => {
|
||||
};
|
||||
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
|
||||
|
||||
console.log('\n── Runtime ──\n');
|
||||
|
||||
/**
|
||||
* bun is deliberately held at a series — `BUN_SERIES` in
|
||||
* scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while.
|
||||
*
|
||||
* The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version
|
||||
* argument (it offers latest-stable or --canary and nothing else), so it always jumps to the
|
||||
* newest release and walks a pinned machine off the pin without saying so. This is the only
|
||||
* thing that notices, which is why a version check sits in a script otherwise about accounts.
|
||||
*
|
||||
* The wanted series is READ from `engines.bun` rather than repeated here, because a second
|
||||
* hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an
|
||||
* install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is
|
||||
* the declaration and this is the enforcement.
|
||||
*/
|
||||
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as {
|
||||
engines?: { bun?: string };
|
||||
};
|
||||
const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, '');
|
||||
const nowSeries = Bun.version.split('.').slice(0, 2).join('.');
|
||||
|
||||
if (!wantSeries) {
|
||||
fail(
|
||||
'package.json declares no engines.bun',
|
||||
'The setup scripts pin a bun series and nothing else records which one. Add it.',
|
||||
);
|
||||
} else if (nowSeries === wantSeries) {
|
||||
pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`);
|
||||
} else {
|
||||
fail(
|
||||
`bun ${Bun.version} is outside the pinned ${wantSeries} series`,
|
||||
`Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` +
|
||||
`'curl -fsSL https://bun.sh/install | bash -s bun-v<newest ${wantSeries}.x>', or raise ` +
|
||||
`engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n── Accounts ──\n');
|
||||
|
||||
const users = await getUsers();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
|
||||
|
||||
── Why generated ──
|
||||
|
||||
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
|
||||
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
|
||||
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
|
||||
|
||||
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
|
||||
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
|
||||
and the prose is limited to what no registry can express.
|
||||
|
||||
── The split, and where the line is ──
|
||||
|
||||
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
|
||||
keyboard shortcuts
|
||||
PROSE what the platform IS, what a permission KIND means for a user, where to look next
|
||||
|
||||
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
|
||||
differs, so a stale skill is a failed check rather than a confident wrong answer.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
|
||||
|
||||
const KIND_MEANING: Record<string, string> = {
|
||||
core: 'every account has it and it cannot be denied — the caller acting on themselves',
|
||||
app: 'grantable per role, at read or write',
|
||||
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
|
||||
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
|
||||
admin: 'the server owner only',
|
||||
};
|
||||
|
||||
export async function renderPlatformSkill(): Promise<string> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||
|
||||
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||
for (const p of PERMISSIONS) {
|
||||
const list = byKind.get(p.kind) ?? [];
|
||||
list.push(p);
|
||||
byKind.set(p.kind, list as typeof PERMISSIONS);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const out = (s = '') => lines.push(s);
|
||||
|
||||
out('---');
|
||||
out('name: Officer platform');
|
||||
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||||
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||||
out('description: >-');
|
||||
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
|
||||
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
|
||||
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
|
||||
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
|
||||
out('version: 1');
|
||||
out('---');
|
||||
out();
|
||||
out('<!--');
|
||||
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
|
||||
out(' differs from what the generator produces, and an edit here is lost on the next run.');
|
||||
out();
|
||||
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
|
||||
out('-->');
|
||||
out();
|
||||
out('# What Officer is');
|
||||
out();
|
||||
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
|
||||
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
|
||||
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
|
||||
out('user, and **absence denies** — there is no row meaning "no".');
|
||||
out();
|
||||
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
|
||||
out('no, and *not shareable at all*.');
|
||||
out();
|
||||
out('# Permissions');
|
||||
out();
|
||||
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
|
||||
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
|
||||
out('diligence.');
|
||||
out();
|
||||
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
|
||||
const entries = byKind.get(kind) ?? [];
|
||||
if (!entries.length) continue;
|
||||
out(`## \`${kind}\` — ${meaning}`);
|
||||
out();
|
||||
for (const p of entries) {
|
||||
const bits: string[] = [];
|
||||
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
|
||||
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
|
||||
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
|
||||
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
|
||||
if (bits.length) out(` - ${bits.join(' · ')}`);
|
||||
if (p.selfService?.length) {
|
||||
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
|
||||
}
|
||||
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
|
||||
if (p.readOnlyWrites?.length) {
|
||||
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
|
||||
}
|
||||
}
|
||||
out();
|
||||
}
|
||||
|
||||
out('# Keyboard shortcuts');
|
||||
out();
|
||||
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||
out('list rather than remembering one.');
|
||||
out();
|
||||
for (const section of SHORTCUT_SECTIONS) {
|
||||
out(`## ${section.title}`);
|
||||
if (section.note) {
|
||||
out();
|
||||
out(section.note);
|
||||
}
|
||||
out();
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
|
||||
out();
|
||||
}
|
||||
|
||||
out('# What is NOT in here');
|
||||
out();
|
||||
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
|
||||
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
|
||||
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
|
||||
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
|
||||
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
|
||||
out(' commander is, how one is started, and how to help somebody shape one.');
|
||||
out();
|
||||
out('# When you are about to guess');
|
||||
out();
|
||||
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
|
||||
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
|
||||
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
|
||||
out('certain as one quoting a true one.');
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const body = await renderPlatformSkill();
|
||||
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
|
||||
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
|
||||
if (existing === body) {
|
||||
console.log('skills:platform — already current');
|
||||
} else {
|
||||
writeFileSync(SKILL_PATH, body);
|
||||
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
|
||||
}
|
||||
}
|
||||
@@ -206,7 +206,7 @@ nvim_clone_config() {
|
||||
# every process here. officer-pty imports node-pty, a native addon with
|
||||
# no Linux prebuild — it compiles against the installed Node on every
|
||||
# machine. Either one alone makes Node load-bearing.
|
||||
# bun the platform itself and nineteen of the twenty pm2 apps.
|
||||
# bun the platform itself, and every pm2 app except officer-pty.
|
||||
# pm2 the process manager the ecosystem files are written for.
|
||||
#
|
||||
# Deno is not. Nothing in the platform imports it — checked across the whole
|
||||
@@ -243,6 +243,69 @@ install_node() {
|
||||
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ── Held at the 1.3 series, deliberately and temporarily ──
|
||||
#
|
||||
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
|
||||
# newest, so a machine provisioned two days later puts a brand-new release under
|
||||
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
|
||||
# for a while. That is the owner's call and it is meant to be revisited in a few
|
||||
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
|
||||
#
|
||||
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
|
||||
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
|
||||
# exactly the way setup_22.x did for Node.
|
||||
#
|
||||
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
|
||||
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
|
||||
# offers latest-stable or --canary and nothing else — so it always jumps to the
|
||||
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
|
||||
# setup script can prevent that. `bun run doctor` is where that drift is caught.
|
||||
BUN_SERIES="${BUN_SERIES:-1.3}"
|
||||
|
||||
# The newest stable release inside the series.
|
||||
#
|
||||
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
|
||||
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
|
||||
# IP, and it returned 504 twice while this was being written. An empty answer here
|
||||
# means no bun gets installed at all (see install_bun), so where it comes from is
|
||||
# load-bearing rather than a matter of taste. bun publishes every release to npm.
|
||||
#
|
||||
# `sort -V` rather than trusting the order the registry returns them in, and the
|
||||
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
|
||||
bun_series_latest() {
|
||||
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
|
||||
jq -r '.versions | keys[]' 2>/dev/null |
|
||||
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
|
||||
sort -V | tail -1
|
||||
}
|
||||
|
||||
# The major.minor of a full version — "1.3.14" is in series "1.3".
|
||||
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
|
||||
|
||||
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
|
||||
#
|
||||
# Above and below are different problems and get different answers — above is a
|
||||
# machine that has upgraded itself past the pin, below is one that is simply old —
|
||||
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
|
||||
# nothing here has to parse a version.
|
||||
bun_series_position() {
|
||||
local now
|
||||
now="$(bun_series_of "${1:-}")"
|
||||
[[ -n "$now" ]] || {
|
||||
echo unknown
|
||||
return
|
||||
}
|
||||
[[ "$now" == "$BUN_SERIES" ]] && {
|
||||
echo in
|
||||
return
|
||||
}
|
||||
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
|
||||
echo above
|
||||
else
|
||||
echo below
|
||||
fi
|
||||
}
|
||||
|
||||
# Present anywhere: on PATH for this root shell, or in the account's own
|
||||
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
||||
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
||||
@@ -260,8 +323,8 @@ bun_version() {
|
||||
# The system-wide link, ensured on every run rather than only after an install.
|
||||
#
|
||||
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
||||
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
|
||||
# then fails to start on reboot while working perfectly when started by hand. A
|
||||
# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
|
||||
# reboot while working perfectly when started by hand. A
|
||||
# machine that already had bun before this script ran would never get the link if
|
||||
# it were only made as part of installing.
|
||||
#
|
||||
@@ -280,8 +343,22 @@ ensure_bun_symlink() {
|
||||
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
||||
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
||||
# started by hand, which is a miserable thing to debug.
|
||||
#
|
||||
# The version goes in as the installer's first positional argument, which it
|
||||
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
|
||||
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
|
||||
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
|
||||
# rather than assuming the baseline the newest release was built for — so this both
|
||||
# installs and pins back down.
|
||||
#
|
||||
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
|
||||
# the registry, and installing whatever is newest is the single outcome the pin
|
||||
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
|
||||
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
|
||||
install_bun() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
|
||||
local tag="${1:-}"
|
||||
[[ -n "$tag" ]] || return 1
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||
}
|
||||
|
||||
|
||||
@@ -1981,14 +1981,18 @@ step "JavaScript runtimes"
|
||||
if ! skip; then
|
||||
NODE_LTS="$(node_lts_major)"
|
||||
NODE_NOW="$(node_installed_major)"
|
||||
# Resolved before the banner so it can state the pin rather than only the version
|
||||
# that happens to be here. One network call, same as node_lts_label above it.
|
||||
BUN_WANT="$(bun_series_latest)"
|
||||
BUN_NOW="$(bun_version)"
|
||||
|
||||
echo ""
|
||||
info "JavaScript runtimes — required, so these are installed rather than offered"
|
||||
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
|
||||
echo " pm2 is a Node application, and officer-pty compiles node-pty"
|
||||
echo " against whatever Node is installed — there is no Linux prebuild."
|
||||
echo " bun $(bun_installed && bun_version || echo 'not installed')"
|
||||
echo " the platform itself, and nineteen of the twenty pm2 apps."
|
||||
echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}"
|
||||
echo " the platform itself, and every pm2 app except officer-pty."
|
||||
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
|
||||
echo " supervises all of them, and the ecosystem files are written for it."
|
||||
echo ""
|
||||
@@ -2014,18 +2018,93 @@ if ! skip; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── bun ──
|
||||
if bun_installed; then
|
||||
ok "bun $(bun_version) already installed"
|
||||
else
|
||||
info " installing bun..."
|
||||
if install_bun && bun_installed; then
|
||||
# ── bun, held at the pinned series ──
|
||||
#
|
||||
# Four states, because "installed" is not one answer. A machine can be inside the
|
||||
# pin, behind it, or past it — and past it is the case worth catching, since that
|
||||
# is what a `bun upgrade` leaves behind and nothing else reports it.
|
||||
if [[ -z "$BUN_WANT" ]]; then
|
||||
# No pin resolved means no install: taking whatever bun.sh serves is the one
|
||||
# thing the pin exists to stop.
|
||||
warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x"
|
||||
if bun_installed; then
|
||||
echo " leaving bun ${BUN_NOW} where it is"
|
||||
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone")
|
||||
else
|
||||
echo " nothing installed — this will not install an unpinned bun, and"
|
||||
echo " officer-setup stops on a missing bun rather than building on 1.4."
|
||||
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed")
|
||||
fi
|
||||
|
||||
elif ! bun_installed; then
|
||||
info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..."
|
||||
if install_bun "bun-v${BUN_WANT}" && bun_installed; then
|
||||
ok "bun $(bun_version)"
|
||||
SUMMARY+=("Bun: $(bun_version)")
|
||||
SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)")
|
||||
else
|
||||
warn "the bun install did not complete"
|
||||
ERRORS+=("Bun: install failed")
|
||||
fi
|
||||
|
||||
else
|
||||
case "$(bun_series_position "$BUN_NOW")" in
|
||||
in)
|
||||
if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then
|
||||
ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series"
|
||||
else
|
||||
info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out"
|
||||
echo " A patch inside the series is what the pin is meant to let"
|
||||
echo " through. Note that 'bun upgrade' would not stop there — it"
|
||||
echo " takes the newest release, which is past the pin."
|
||||
if confirm "Move to ${BUN_WANT}?"; then
|
||||
if install_bun "bun-v${BUN_WANT}"; then
|
||||
ok "bun $(bun_version)"
|
||||
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
|
||||
else
|
||||
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||
ERRORS+=("Bun: could not move to ${BUN_WANT}")
|
||||
fi
|
||||
else
|
||||
echo " left at ${BUN_NOW}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
above)
|
||||
# The `bun upgrade` case. Reported rather than corrected silently: moving
|
||||
# somebody's runtime backwards is a decision, and this is the only place
|
||||
# that knows it happened.
|
||||
warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series"
|
||||
echo " Most likely a 'bun upgrade', which takes no version argument and"
|
||||
echo " always jumps to the newest release. The platform and every pm2"
|
||||
echo " app except officer-pty run on this."
|
||||
if confirm "Pin it back to ${BUN_WANT}?"; then
|
||||
if install_bun "bun-v${BUN_WANT}"; then
|
||||
ok "bun $(bun_version) — back inside the pin"
|
||||
SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)")
|
||||
else
|
||||
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||
ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}")
|
||||
fi
|
||||
else
|
||||
warn "left at ${BUN_NOW}, outside the pin"
|
||||
SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice")
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series"
|
||||
if confirm "Move to ${BUN_WANT}?"; then
|
||||
if install_bun "bun-v${BUN_WANT}"; then
|
||||
ok "bun $(bun_version)"
|
||||
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
|
||||
else
|
||||
warn "the install did not complete — bun ${BUN_NOW} is untouched"
|
||||
ERRORS+=("Bun: could not move to ${BUN_WANT}")
|
||||
fi
|
||||
else
|
||||
echo " left at ${BUN_NOW}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Ensured every run, not only after an install. A machine that already had bun
|
||||
|
||||
@@ -760,7 +760,7 @@ if ! skip; then
|
||||
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
||||
echo " install and nothing else, so nothing in git can drift from it."
|
||||
echo ""
|
||||
echo " six processes:"
|
||||
echo " ${#CORE_PROCESSES[@]} processes:"
|
||||
for entry in "${CORE_PROCESSES[@]}"; do
|
||||
IFS='|' read -r _name _script _args <<<"$entry"
|
||||
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
||||
@@ -772,7 +772,7 @@ if ! skip; then
|
||||
if confirm "Write it and start them?"; then
|
||||
write_ecosystem
|
||||
ok "written — $(ecosystem_file)"
|
||||
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
report_changed "wrote $(ecosystem_file) — ${#CORE_PROCESSES[@]} pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
|
||||
# Starting against a database that is not answering is not fatal — the server
|
||||
# waits and the agent retries forever — but it makes the Verify section below
|
||||
@@ -792,13 +792,13 @@ if ! skip; then
|
||||
if pm2_enable_startup; then
|
||||
ok "pm2 will resurrect them at boot"
|
||||
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
||||
SUMMARY+=("Services: 6 processes started, enabled at boot")
|
||||
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
|
||||
else
|
||||
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
||||
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
|
||||
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
|
||||
fi
|
||||
else
|
||||
SUMMARY+=("Services: 6 processes started; not enabled at boot")
|
||||
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; not enabled at boot")
|
||||
fi
|
||||
else
|
||||
warn "pm2 did not start cleanly"
|
||||
@@ -945,7 +945,7 @@ fi
|
||||
#
|
||||
# Root, and that surprises people — reasonably, because everything this script just
|
||||
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
||||
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
|
||||
# node_modules, .env, the secret store and every pm2 process are theirs. Root was
|
||||
# the installer's privilege, never the platform's.
|
||||
#
|
||||
# Saying so matters for two things that are invisible until they bite:
|
||||
|
||||
@@ -59,7 +59,7 @@ tool_why() {
|
||||
case "$1" in
|
||||
git) echo "to clone and update the platform" ;;
|
||||
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
||||
bun) echo "the platform itself and nineteen of the twenty processes" ;;
|
||||
bun) echo "the platform itself and every process except the pty sidecar" ;;
|
||||
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
||||
docker) echo "Postgres, and anything the app store provisions" ;;
|
||||
*) echo "" ;;
|
||||
|
||||
@@ -106,6 +106,7 @@ export function App() {
|
||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||
|
||||
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
|
||||
// /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
|
||||
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
|
||||
// "Route, surface, placement" and "Layout, v1".
|
||||
// "Route, surface, placement".
|
||||
|
||||
export const CommandHqScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
||||
|
||||
/*
|
||||
Move a saved three-column layout onto the two-column one.
|
||||
|
||||
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
|
||||
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
|
||||
appType check, and why replacing outright is the safe direction on a `locked` screen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!workspace.isLoaded) return;
|
||||
if (!isLegacyThreeColumn(workspace.value)) return;
|
||||
workspace.setValue(defaultLayout);
|
||||
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
|
||||
@@ -1,14 +1,60 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
|
||||
// detail pane gets the most room because it is a live chat transcript once something is selected.
|
||||
/*
|
||||
Two columns, with the navigation stacked into the left one.
|
||||
|
||||
── Why it changed from three ──
|
||||
|
||||
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
|
||||
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
|
||||
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
|
||||
enough to see whole.
|
||||
|
||||
Now: commanders on top, categories beneath them, transcript taking three quarters.
|
||||
|
||||
── Why the categories are BELOW the commanders ──
|
||||
|
||||
Because the commanders are the thing you came for and the categories are how you narrow them when there
|
||||
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
|
||||
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
|
||||
the categories can grow their own scrollbar without ever taking room from the commanders.
|
||||
*/
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'command-hq-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
|
||||
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
|
||||
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'command-hq-nav',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
|
||||
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
|
||||
],
|
||||
},
|
||||
size: 24,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Is this a saved copy of the three-column layout this replaced?
|
||||
*
|
||||
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
|
||||
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
|
||||
* horizontal root with THREE children, and the new one has two.
|
||||
*
|
||||
* It terminates for that reason: the replacement does not satisfy the test.
|
||||
*
|
||||
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
|
||||
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
|
||||
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
|
||||
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
|
||||
* copies of one picker.
|
||||
*/
|
||||
export function isLegacyThreeColumn(node: LayoutNode): boolean {
|
||||
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
||||
import { Spotlight } from './Spotlight/Spotlight';
|
||||
import { ShortcutsModal } from './ShortcutsModal';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||
import { RouteGate } from './RouteGate';
|
||||
@@ -51,6 +52,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||
<Spotlight />
|
||||
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
|
||||
panel, an error boundary, or mid-navigation. */}
|
||||
<ShortcutsModal />
|
||||
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
||||
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
||||
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Star } from 'lucide-react';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||
import type { Theme } from '@/components/ui/ThemeProvider';
|
||||
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
||||
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
||||
|
||||
const {
|
||||
DropdownMenu,
|
||||
@@ -21,7 +23,7 @@ const {
|
||||
*
|
||||
* ── The overlay ──
|
||||
*
|
||||
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
||||
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
||||
* you browse, so as little of it as possible should be covered.
|
||||
@@ -30,21 +32,39 @@ const {
|
||||
*/
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, themeId, setTheme, themes } = useTheme();
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys();
|
||||
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
|
||||
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
|
||||
// readonly, so this builds mutable arrays rather than pushing into its own type.
|
||||
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
|
||||
(acc[t.family] ??= []).push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
/*
|
||||
One ordering, used by both lists.
|
||||
|
||||
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
||||
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
||||
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
||||
`theme/rotate.ts`.
|
||||
|
||||
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
||||
the order it was given, so the theme you are wearing still leads.
|
||||
*/
|
||||
const ordered = useMemo(() => {
|
||||
const rotated = rotateToSelected(themes, themeId);
|
||||
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
|
||||
return favouritesFirst(listed, favorites);
|
||||
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
|
||||
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
|
||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
|
||||
isFavorite(themeId) ? 'unstar' : 'star'
|
||||
}`}
|
||||
aria-label={`Theme: ${theme.name}`}
|
||||
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
||||
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
||||
@@ -53,23 +73,74 @@ export const ThemeSwitcher = () => {
|
||||
style={{ color: 'var(--header-fg)' }}
|
||||
>
|
||||
{theme.name}
|
||||
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
||||
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
||||
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
ctrl+shift+←/→ · ctrl+shift+space
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(families).map(([family, entries]) => (
|
||||
<div key={family}>
|
||||
{entries.map((t) => (
|
||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
|
||||
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||
{t.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{/*
|
||||
The CONTENT does not scroll — the list inside it does.
|
||||
|
||||
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
||||
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
||||
visible for the first half-second and then gone for good, which is the same as not having written
|
||||
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
||||
*/}
|
||||
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
||||
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
||||
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
||||
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
|
||||
<span className="block">ctrl+? for every shortcut</span>
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setOnlyFavorites((was) => !was);
|
||||
}}
|
||||
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{ordered.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
No favourites yet — star one with the button on its row.
|
||||
</p>
|
||||
) : (
|
||||
families.map(([family, entries]) => (
|
||||
<div key={family}>
|
||||
{entries.map((t) => (
|
||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
|
||||
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
}}
|
||||
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -90,8 +161,11 @@ export const ThemeSwitcher = () => {
|
||||
}}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{themes.map((t) => {
|
||||
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
||||
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||
{ordered.map((t) => {
|
||||
const isFocused = t.id === focused;
|
||||
return (
|
||||
<li key={t.id}>
|
||||
@@ -101,19 +175,43 @@ export const ThemeSwitcher = () => {
|
||||
setTheme(t.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
|
||||
{/*
|
||||
A span, not a button: this sits inside the row's own <button>, and a button inside
|
||||
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
||||
handler and the role are set explicitly instead, and `f` does the same thing from
|
||||
the keyboard.
|
||||
*/}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
|
||||
onClick={(ev) => {
|
||||
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||
// the opposite of what starring is for.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
||||
header does. */}
|
||||
<p className="mt-6 text-center text-xs opacity-60">
|
||||
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
|
||||
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
||||
|
||||
/*
|
||||
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
||||
|
||||
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
|
||||
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
|
||||
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
|
||||
interesting part is `state/shortcuts.ts`.
|
||||
|
||||
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
|
||||
controls grid, and for the same reason: these are short lists that read badly as one long column.
|
||||
*/
|
||||
|
||||
const Row = ({ keys, description }: { keys: string; description: string }) => (
|
||||
<div className="flex items-baseline gap-3 py-1">
|
||||
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
|
||||
putting them on the left. */}
|
||||
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
|
||||
{keys}
|
||||
</kbd>
|
||||
<span className="text-sm text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ShortcutsModal = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (isShortcutsChord(ev)) {
|
||||
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
|
||||
// composer is a help list you cannot open — the lesson from the Spotlight guard.
|
||||
ev.preventDefault();
|
||||
setOpen((was) => !was);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape' && open) setOpen(false);
|
||||
};
|
||||
// Capture, so a panel with its own key handling cannot swallow it first.
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
|
||||
</div>
|
||||
|
||||
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
||||
{SHORTCUT_SECTIONS.map((section) => (
|
||||
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
|
||||
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
|
||||
<div className="mt-2">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,12 +11,28 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
//
|
||||
// ── Keyboard first, and the trigger ──
|
||||
//
|
||||
// `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner
|
||||
// telling us what the chord actually collides with on the machine he uses:
|
||||
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
|
||||
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
|
||||
//
|
||||
// ctrl-space focuses the address bar in Brave
|
||||
// shift-space types a space — see below, this was the real problem
|
||||
// alt-space taken by the window menu, and the owner rebound it rather than give up the chord
|
||||
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
|
||||
// preventDefault would win. It does not — the key never reaches this handler.
|
||||
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
|
||||
// would rather move the app than the window manager.
|
||||
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
|
||||
//
|
||||
// ── Shift-space overrides two things, on purpose ──
|
||||
//
|
||||
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
|
||||
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
|
||||
// and typing a space is what the unmodified spacebar is for.
|
||||
//
|
||||
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
|
||||
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
|
||||
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
|
||||
// what made the launcher unopenable from the composer, where it is most wanted.
|
||||
//
|
||||
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
|
||||
// is the argument for making them configurable, not for choosing better.
|
||||
//
|
||||
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
|
||||
// combination inside a text field: it types a space, and people hold shift through one more often than
|
||||
@@ -24,7 +40,7 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
|
||||
// A launcher you have to prepare for is not a launcher.
|
||||
//
|
||||
// Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
|
||||
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
|
||||
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
|
||||
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
|
||||
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
|
||||
@@ -200,15 +216,18 @@ export const Spotlight = () => {
|
||||
// anywhere — including inside a panel that has its own key handling.
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
// `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and
|
||||
// nothing at all on some), while the physical key is `Space` everywhere.
|
||||
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
|
||||
// the physical key is `Space` everywhere.
|
||||
//
|
||||
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
|
||||
// both is the bug that took a capture-phase listener to fix on the theme side.
|
||||
//
|
||||
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
|
||||
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
|
||||
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
|
||||
// below covers the only thing the guard was protecting against — a layout that would otherwise type
|
||||
// a character.
|
||||
if (ev.altKey && ev.code === 'Space') {
|
||||
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||
ev.preventDefault();
|
||||
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
|
||||
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB.
|
||||
//
|
||||
// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on
|
||||
// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at
|
||||
// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin
|
||||
// cannot claim one.
|
||||
//
|
||||
// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus
|
||||
// are file dialogs with the owner's home behind them.
|
||||
|
||||
export const MinesweeperScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/minesweeper', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
|
||||
// second panel of empty space.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'minesweeper',
|
||||
appType: 'minesweeper',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './MinesweeperScreen';
|
||||
@@ -22,3 +22,4 @@ export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './Minesweeper';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Every keyboard shortcut the shell owns, as data.
|
||||
|
||||
── Why a registry ──
|
||||
|
||||
Ported in shape from the owner's `vimazing` (`vim-maze/src/gameInfo.ts`), where the controls are declared
|
||||
once and the help screen renders them. The alternative is what this replaced: chords spelled out inside
|
||||
three separate key handlers, and a help text nobody updates because it lives somewhere else.
|
||||
|
||||
Two things follow from having it. `Ctrl+?` can render an accurate list without anybody maintaining prose.
|
||||
And when shortcuts become configurable — the next big piece — this is the table a binding UI edits, rather
|
||||
than a grep across handlers.
|
||||
|
||||
── What it is NOT, yet ──
|
||||
|
||||
Declarative but not authoritative: the handlers still contain their own conditions, so a chord changed here
|
||||
does not move. Making this the single source is part of the configurable-shortcuts work, and doing it now
|
||||
would mean a rewrite of three handlers for no visible gain. Where a chord appears in both places the file
|
||||
that owns it is named in `implementedIn`, so the pair can be found in one search.
|
||||
*/
|
||||
|
||||
export type Shortcut = {
|
||||
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
|
||||
keys: string;
|
||||
description: string;
|
||||
/** The file whose handler actually implements it, until this registry is authoritative. */
|
||||
implementedIn: string;
|
||||
};
|
||||
|
||||
export type ShortcutSection = {
|
||||
title: string;
|
||||
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
|
||||
note?: string;
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
|
||||
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Shift+Space',
|
||||
description: 'Spotlight — go to any screen, plugin or setting by name',
|
||||
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
|
||||
},
|
||||
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Themes',
|
||||
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Ctrl+Shift+Space',
|
||||
description: 'Open the theme browser',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl+Shift+← / →',
|
||||
description: 'Previous / next theme, without opening anything',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl+Shift+F',
|
||||
description: 'Star or unstar the current theme, from anywhere',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In the theme browser',
|
||||
shortcuts: [
|
||||
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{
|
||||
keys: 'l →',
|
||||
description: 'Apply the focused theme and keep browsing',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In Spotlight',
|
||||
shortcuts: [
|
||||
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Chat',
|
||||
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: '↑ / ↓',
|
||||
description: 'Previous / next prompt you sent, shell-style',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Enter',
|
||||
description: 'Send · on an empty composer with something queued, send that now',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
|
||||
{
|
||||
keys: 'Esc',
|
||||
description: 'Stop the turn that is generating',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Is this the `Ctrl+?` chord?
|
||||
*
|
||||
* `?` is `Shift+/` on most layouts and its own key on some, so both spellings are accepted rather than
|
||||
* guessing which keyboard is attached. `code === 'Slash'` is the physical key, which is stable where `key`
|
||||
* is not.
|
||||
*/
|
||||
export function isShortcutsChord(ev: KeyboardEvent): boolean {
|
||||
if (!ev.ctrlKey) return false;
|
||||
return ev.key === '?' || (ev.shiftKey && ev.code === 'Slash');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
/*
|
||||
Starred themes, per account.
|
||||
|
||||
With 200+ installed, "which of these did I like" is a question the list cannot answer by itself, and the
|
||||
answer belongs to the account rather than to the browser — the same taste follows you to the phone.
|
||||
|
||||
Optimistic, and that is not a nicety here: the star sits in a dropdown you are about to close, so a
|
||||
round trip before the icon fills means the confirmation arrives after the thing it confirms has gone.
|
||||
*/
|
||||
|
||||
const KEY = ['themes', 'favorites'];
|
||||
|
||||
export function useThemeFavorites() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery<{ favorites: string[] }>({
|
||||
queryKey: KEY,
|
||||
queryFn: () => client.get<{ favorites: string[] }>('/theme-favorites'),
|
||||
// Taste does not change while you look at it, and the mutation below keeps the cache honest.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ themeId, favorite }: { themeId: string; favorite: boolean }) =>
|
||||
client.put<{ themeId: string; favorite: boolean }>(`/theme-favorites/${themeId}`, { favorite }),
|
||||
onMutate: async ({ themeId, favorite }) => {
|
||||
await queryClient.cancelQueries({ queryKey: KEY });
|
||||
const previous = queryClient.getQueryData<{ favorites: string[] }>(KEY);
|
||||
const current = previous?.favorites ?? [];
|
||||
queryClient.setQueryData(KEY, {
|
||||
favorites: favorite ? [...new Set([...current, themeId])] : current.filter((id) => id !== themeId),
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
// Put it back on failure. A star that stayed filled after the write was refused is worse than one that
|
||||
// never filled: it reports a preference the server does not hold.
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(KEY, context.previous);
|
||||
},
|
||||
});
|
||||
|
||||
const favorites = new Set(query.data?.favorites ?? []);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
isFavorite: (themeId: string) => favorites.has(themeId),
|
||||
toggle: (themeId: string) => toggle.mutate({ themeId, favorite: !favorites.has(themeId) }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { chatSessionEvents } from './chat-events/schema';
|
||||
import { chatSessionModels } from './chat-session-models/schema';
|
||||
import { forgetChatSessions } from './chat-session-cleanup';
|
||||
|
||||
/*
|
||||
Deleting a conversation, across the two id spaces it actually spans.
|
||||
|
||||
This file touches the real database, which the rest of the suite avoids on purpose. It has to: the defect
|
||||
it exists to catch was never in a decision, it was in which column a `delete` matched. A test built on a
|
||||
fake store would have passed against both the broken version and the fixed one.
|
||||
|
||||
Safe because every id here is a synthetic uuid in a reserved block that no harness produces, and because
|
||||
each test removes exactly what it wrote. Nothing reads live rows and nothing deletes by anything but these
|
||||
ids.
|
||||
|
||||
Two regressions are pinned here, and they are the same bug seen twice:
|
||||
|
||||
1. `chat_session_events` is keyed on officer's SESSION KEY while everything else is keyed on the TRANSCRIPT
|
||||
id. One list passed to all four tables matched nothing in this one, ever, by construction — and counting
|
||||
events by transcript id to check also returns zero by construction, so it read as clean.
|
||||
2. Resolving that key from a live in-memory map only works for a session younger than the process. After a
|
||||
restart it comes back null, the key list arrives empty, and the events survive a delete that reports
|
||||
success. The key has to come from the rows, which carry `claudeSessionId` in their payload.
|
||||
*/
|
||||
|
||||
const TRANSCRIPT = '00000000-dead-4000-8000-000000000001';
|
||||
const SESSION_KEY = '00000000-dead-4000-8000-0000000000ff';
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, [SESSION_KEY]));
|
||||
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, [TRANSCRIPT]));
|
||||
});
|
||||
|
||||
const countEvents = async () =>
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(chatSessionEvents)
|
||||
.where(inArray(chatSessionEvents.sessionId, [SESSION_KEY]))
|
||||
).length;
|
||||
|
||||
describe('forgetChatSessions', () => {
|
||||
test('removes events keyed on the session key, found via the transcript id in their payload', async () => {
|
||||
// Exactly the real shape: one `result` carries both ids, the rest carry neither.
|
||||
await db.insert(chatSessionEvents).values([
|
||||
{ sessionId: SESSION_KEY, event: { type: 'result', sessionId: SESSION_KEY, claudeSessionId: TRANSCRIPT } },
|
||||
{ sessionId: SESSION_KEY, event: { type: 'assistant:text', text: 'no ids on this one' } },
|
||||
{ sessionId: SESSION_KEY, event: { type: 'tool:start', toolName: 'Bash' } },
|
||||
]);
|
||||
expect(await countEvents()).toBe(3);
|
||||
|
||||
// No `sessionKeys` — this is the post-restart case, where the caller cannot resolve one.
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT] });
|
||||
|
||||
// All three, not just the one carrying the id: resolving the key covers the whole session.
|
||||
expect(await countEvents()).toBe(0);
|
||||
});
|
||||
|
||||
test('an explicit session key still works when no event names the transcript', async () => {
|
||||
await db
|
||||
.insert(chatSessionEvents)
|
||||
.values([{ sessionId: SESSION_KEY, event: { type: 'assistant:text', text: 'nothing identifying here' } }]);
|
||||
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT], sessionKeys: [SESSION_KEY] });
|
||||
expect(await countEvents()).toBe(0);
|
||||
});
|
||||
|
||||
test('leaves another session alone', async () => {
|
||||
const OTHER = '00000000-dead-4000-8000-0000000000ee';
|
||||
await db
|
||||
.insert(chatSessionEvents)
|
||||
.values([{ sessionId: OTHER, event: { type: 'assistant:text', text: 'theirs' } }]);
|
||||
try {
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT], sessionKeys: [SESSION_KEY] });
|
||||
const left = await db
|
||||
.select()
|
||||
.from(chatSessionEvents)
|
||||
.where(inArray(chatSessionEvents.sessionId, [OTHER]));
|
||||
expect(left.length).toBe(1);
|
||||
} finally {
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, [OTHER]));
|
||||
}
|
||||
});
|
||||
|
||||
test('forgets the transcript-keyed tables too', async () => {
|
||||
await db.insert(chatSessionModels).values({ sessionId: TRANSCRIPT, model: 'claude-code/opus', planMode: false });
|
||||
await forgetChatSessions({ transcriptIds: [TRANSCRIPT] });
|
||||
const left = await db
|
||||
.select()
|
||||
.from(chatSessionModels)
|
||||
.where(inArray(chatSessionModels.sessionId, [TRANSCRIPT]));
|
||||
expect(left.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { chatSessionEvents } from './chat-events/schema';
|
||||
import { chatNotificationPrefs } from './chat-notification-prefs/schema';
|
||||
import { chatSessionModels } from './chat-session-models/schema';
|
||||
import { nudges } from './nudges/schema';
|
||||
|
||||
/*
|
||||
Everything the platform stores ABOUT a conversation, forgotten together.
|
||||
|
||||
── Why this exists as one function ──
|
||||
|
||||
Four tables describe a conversation, and none has a foreign key to anything, because the thing they
|
||||
describe is a FILE — a transcript on disk, written by the harness. There is no row for a chat session to
|
||||
reference, so the database cannot cascade and the delete path has to be explicit.
|
||||
|
||||
It was not. Deleting a conversation removed the transcript and left all four behind, for ever. Found on
|
||||
2026-08-22 by counting rows after two commanders were deleted through the UI: `chat_session_models` held 36
|
||||
rows against far fewer live conversations.
|
||||
|
||||
── Why it is more than untidy ──
|
||||
|
||||
Three of the four are inert — a model, a bell preference, a durable copy of turn output that nothing will
|
||||
ask for again. `nudges` is not: it is a SCHEDULE, and the tick in `nudger.ts` reads it. A nudge left behind
|
||||
by a deleted conversation is a timer that keeps firing at something that does not exist.
|
||||
|
||||
── TWO id spaces, and this is where they were confused ──
|
||||
|
||||
Three of these tables are keyed on the TRANSCRIPT id — the harness's uuid, which is also the filename.
|
||||
`chat_session_events` is keyed on officer's own SESSION KEY, which is a different uuid for the same
|
||||
conversation. Its rows carry both (`{sessionId, claudeSessionId}`), which is how the mismatch was finally
|
||||
proved rather than argued.
|
||||
|
||||
This function used to take one list and pass it to all four. So the events delete matched nothing, ever —
|
||||
not sometimes, by construction — and a deleted conversation left its entire durable output behind. The
|
||||
verification missed it twice, because counting events BY TRANSCRIPT ID also returns zero by construction,
|
||||
and zero read as "cleaned". Found 2026-08-22 by reading an orphaned event payload instead of counting it.
|
||||
|
||||
The parameter is named for its id space so a caller cannot pass the wrong one without writing the wrong
|
||||
word. That is the entire defence: nothing else about the two distinguishes them, since both are uuids.
|
||||
|
||||
── Why each takes a list ──
|
||||
|
||||
A `/clear` chain is several transcripts presenting as one conversation, and deleting it deletes every part.
|
||||
Forgetting one id and leaving its siblings is the same bug at a smaller scale, so the caller passes the ids
|
||||
it actually removed.
|
||||
*/
|
||||
type ForgetChatSessionsParams = {
|
||||
/** Harness transcript uuids — the filenames that were removed, including every part of a `/clear` chain. */
|
||||
transcriptIds: string[];
|
||||
/**
|
||||
* Officer's session keys for the same conversations, for `chat_session_events` alone.
|
||||
*
|
||||
* Empty is legitimate and common: a session spawned by `claude -p` outside officer's websocket never had a
|
||||
* key and never wrote an event, which is exactly the case for a commander's children.
|
||||
*/
|
||||
sessionKeys?: string[];
|
||||
};
|
||||
|
||||
export async function forgetChatSessions({ transcriptIds, sessionKeys = [] }: ForgetChatSessionsParams): Promise<void> {
|
||||
// Sequential rather than a transaction: these are independent tables with no invariant between them, and
|
||||
// a partial forget is strictly better than a failed delete that leaves the transcript gone and every row
|
||||
// in place. Each is idempotent — running it twice removes nothing the second time.
|
||||
if (transcriptIds.length) {
|
||||
await db.delete(nudges).where(inArray(nudges.sessionId, transcriptIds));
|
||||
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, transcriptIds));
|
||||
await db.delete(chatNotificationPrefs).where(inArray(chatNotificationPrefs.sessionId, transcriptIds));
|
||||
}
|
||||
/*
|
||||
Events, resolved from the table's OWN data rather than from the caller's key.
|
||||
|
||||
`sessionKeys` is a fast path and not a reliable one: the caller gets it from an in-memory map that is
|
||||
empty for any session created before the last restart, so the first real test of this deleted everything
|
||||
except the events — the key came back null and the list was empty. A cleanup that only works for
|
||||
conversations younger than the process is not a cleanup.
|
||||
|
||||
So ask the rows. Every event carries `claudeSessionId` in its payload, which is durable, survives any
|
||||
restart, and is the one place the two id spaces are written down together. One id inside the JSON is
|
||||
enough to resolve the key for all of that session's rows, including the events that carry no id of their
|
||||
own — which is most of them.
|
||||
*/
|
||||
if (transcriptIds.length) {
|
||||
const keysFromEvents = await db
|
||||
.selectDistinct({ sessionId: chatSessionEvents.sessionId })
|
||||
.from(chatSessionEvents)
|
||||
.where(inArray(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptIds));
|
||||
const keys = [...new Set([...sessionKeys, ...keysFromEvents.map((r) => r.sessionId)])];
|
||||
if (keys.length) await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, keys));
|
||||
} else if (sessionKeys.length) {
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, sessionKeys));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Officer's session key for a transcript, read from the durable event log.
|
||||
*
|
||||
* The in-memory maps (`sessionKeyForTranscript`, `findSessionKeyByClaudeSession`) only answer for a session
|
||||
* younger than the process that is asking. After a restart both return undefined, and the delete path then
|
||||
* skips the kill entirely — so the transcript is removed while the harness is still writing it, and the
|
||||
* harness writes it straight back. A deleted commander reappears as an untitled stub.
|
||||
*
|
||||
* Every `result` event carries both ids in its payload, which survives any restart. Same trick as
|
||||
* `forgetChatSessions`, and for the same reason: the rows are the only place the two id spaces are written
|
||||
* down together.
|
||||
*/
|
||||
export async function sessionKeyForTranscriptId(transcriptId: string): Promise<string | undefined> {
|
||||
const [row] = await db
|
||||
.select({ sessionId: chatSessionEvents.sessionId })
|
||||
.from(chatSessionEvents)
|
||||
.where(eq(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptId))
|
||||
.limit(1);
|
||||
return row?.sessionId;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export {
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
recordCommandHqTurn,
|
||||
ingestSpawnedChildren,
|
||||
recordSpawnedOutcome,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
listCommandHqWatcherFires,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
commandHqTags,
|
||||
commandHqTurns,
|
||||
commandHqWatcherFires,
|
||||
commandHqSpawned,
|
||||
} from './schema';
|
||||
|
||||
// ── Categories ──
|
||||
@@ -54,6 +55,8 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
|
||||
export type CommandHqSessionRow = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
@@ -66,6 +69,7 @@ export type CommandHqSessionRow = {
|
||||
const SESSION_ROW_COLUMNS = {
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
@@ -83,10 +87,11 @@ export async function createCommandHqSession(
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
categoryId: number | null = null,
|
||||
name: string | null = null,
|
||||
): Promise<CommandHqSessionRow> {
|
||||
const [row] = await db
|
||||
.insert(commandHqSessions)
|
||||
.values({ userId, sessionId, categoryId })
|
||||
.values({ userId, sessionId, categoryId, name })
|
||||
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
|
||||
@@ -405,3 +410,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num
|
||||
.delete(commandHqSessionTags)
|
||||
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
/*
|
||||
Spawned children. The file is the inbox; these rows are the record — see the schema note.
|
||||
*/
|
||||
|
||||
export type SpawnedChild = {
|
||||
spawnedSessionId: string;
|
||||
firstSeenAt: string;
|
||||
finishedAt: string | null;
|
||||
totalUsd: number | null;
|
||||
outcome: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ingest ids the loop appended, idempotently, and return the commander's children.
|
||||
*
|
||||
* One statement for the insert because the loop can repeat itself after a restart, so a duplicate append is
|
||||
* expected rather than exceptional. `onConflictDoNothing` on the unique index makes re-ingesting the whole
|
||||
* file cost nothing and change nothing, which is what lets the caller ingest on every read instead of
|
||||
* running a cron that can silently stop.
|
||||
*/
|
||||
export async function ingestSpawnedChildren(sessionRowId: number, ids: string[]): Promise<SpawnedChild[]> {
|
||||
if (ids.length) {
|
||||
await db
|
||||
.insert(commandHqSpawned)
|
||||
.values(ids.map((spawnedSessionId) => ({ sessionRowId, spawnedSessionId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(commandHqSpawned)
|
||||
.where(eq(commandHqSpawned.sessionRowId, sessionRowId))
|
||||
.orderBy(commandHqSpawned.firstSeenAt);
|
||||
return rows.map((r) => ({
|
||||
spawnedSessionId: r.spawnedSessionId,
|
||||
firstSeenAt: r.firstSeenAt.toISOString(),
|
||||
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||
totalUsd: r.totalUsd,
|
||||
outcome: r.outcome,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a child cost, once something knows.
|
||||
*
|
||||
* Separate from the ingest because the two facts arrive at different times from different parties: the loop
|
||||
* knows the id at spawn, and only the child knows what it cost when it exits. Null cost stays null — an
|
||||
* unknown total must not be reported as zero.
|
||||
*/
|
||||
export async function recordSpawnedOutcome(
|
||||
sessionRowId: number,
|
||||
spawnedSessionId: string,
|
||||
outcome: { totalUsd?: number | null; outcome?: string | null },
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(commandHqSpawned)
|
||||
.set({
|
||||
finishedAt: new Date(),
|
||||
...(outcome.totalUsd === undefined ? {} : { totalUsd: outcome.totalUsd }),
|
||||
...(outcome.outcome === undefined ? {} : { outcome: outcome.outcome }),
|
||||
})
|
||||
.where(
|
||||
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@ export const commandHqSessions = pgTable(
|
||||
* id spaces both agree on only sometimes.
|
||||
*/
|
||||
sessionId: text('session_id').notNull(),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
* The summary is the harness's, and it can only be written once the transcript file exists — which is
|
||||
* after the first turn. So a list reading the transcript showed the opening brief until that turn ended,
|
||||
* while the header beside it showed the name from local state. Two names for one commander, and every
|
||||
* fix for it was a cache-invalidation trick timed against somebody else's write.
|
||||
*
|
||||
* The row is written when the commander is filed, before any of that. Nullable for the rows that predate
|
||||
* this, which fall back to the transcript.
|
||||
*/
|
||||
name: text('name'),
|
||||
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
|
||||
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
||||
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
||||
@@ -195,3 +207,53 @@ export const commandHqSessionTags = pgTable(
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per agent a commander spawned.
|
||||
*
|
||||
* ── Why the database, when the loop writes a file ──
|
||||
*
|
||||
* The file is an INBOX, not the record. A commander's loop is bash: it can append a session id with a shell
|
||||
* redirect and it cannot reach Postgres, and giving it a credential so it could would put an authenticated
|
||||
* writer inside a background job — worse than the problem. So the loop appends, and the platform ingests
|
||||
* into this table whenever anything asks. The file is allowed to be lossy; this is not.
|
||||
*
|
||||
* What the table buys that the file cannot:
|
||||
*
|
||||
* - the LINK, enforced. `onDelete: 'cascade'` means deleting a commander cannot leave orphaned children
|
||||
* behind, which is the whole reason this became important.
|
||||
* - queries. Cost per commander, how many agents a trigger spawned, which ones are still running — none of
|
||||
* which a newline-delimited file can answer without reading every one.
|
||||
* - a place for what the loop cannot know at append time: when the child finished, what it cost, whether
|
||||
* it succeeded. Those arrive later, from the child itself, and a file appended by a shell cannot be
|
||||
* updated in place.
|
||||
*
|
||||
* ── Why the child's own id is the key and not a serial ──
|
||||
*
|
||||
* Because the same child must not be ingested twice — the file is appended to by a loop that may repeat
|
||||
* itself after a restart, and the ingest is idempotent on `(session_row_id, spawned_session_id)`.
|
||||
*/
|
||||
export const commandHqSpawned = pgTable(
|
||||
'command_hq_spawned',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The commander. Cascades: its children go with it, which is the point. */
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
/** The child's own transcript id, as the harness wrote it. */
|
||||
spawnedSessionId: text('spawned_session_id').notNull(),
|
||||
/** When the platform first saw it in the inbox — not when the child started, which nothing records. */
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/**
|
||||
* Filled in later, by whoever finds out. Null means unknown rather than zero — a commander whose
|
||||
* children's costs are unknown must not display a confident total that happens to be too low, which is
|
||||
* the failure this table exists to end.
|
||||
*/
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
totalUsd: doublePrecision('total_usd'),
|
||||
outcome: text('outcome'),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
(table) => [uniqueIndex('uq_command_hq_spawned').on(table.sessionRowId, table.spawnedSessionId)],
|
||||
);
|
||||
|
||||
@@ -38,6 +38,8 @@ export * from './chat-notification-prefs';
|
||||
export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -35,6 +35,7 @@ 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 './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
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,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { themeFavorites } from './schema';
|
||||
|
||||
/** The theme ids this account has starred. Order is not meaningful — the caller owns the ordering. */
|
||||
export async function listThemeFavorites(userId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ themeId: themeFavorites.themeId })
|
||||
.from(themeFavorites)
|
||||
.where(eq(themeFavorites.userId, userId));
|
||||
return rows.map((r) => r.themeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Star or unstar, returning the state it ended in.
|
||||
*
|
||||
* Idempotent in both directions on purpose: a double-tap, or two tabs racing, must not end with the star
|
||||
* in a state neither of them asked for. `onConflictDoNothing` makes the insert safe to repeat, and the
|
||||
* delete is naturally so.
|
||||
*/
|
||||
export async function setThemeFavorite(userId: number, themeId: string, favorite: boolean): Promise<boolean> {
|
||||
if (favorite) {
|
||||
await db.insert(themeFavorites).values({ userId, themeId }).onConflictDoNothing();
|
||||
return true;
|
||||
}
|
||||
await db.delete(themeFavorites).where(and(eq(themeFavorites.userId, userId), eq(themeFavorites.themeId, themeId)));
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* Which themes an account has starred.
|
||||
*
|
||||
* ── Why a table, when a preference could be one JSON column ──
|
||||
*
|
||||
* Because it is per account and unbounded. `user_settings` would hold an array that grows with taste and
|
||||
* has to be read-modify-written by whoever toggles a star, and two tabs doing that at once lose one of the
|
||||
* stars. A row per favourite makes a toggle an INSERT or a DELETE, which cannot race with itself.
|
||||
*
|
||||
* ── Why the theme id is text with no foreign key ──
|
||||
*
|
||||
* Themes are not database rows. Most are compiled into the bundle; the rest are directories under
|
||||
* `platform/themes/` discovered at request time, so a theme can appear or vanish without anything writing
|
||||
* SQL. A foreign key would either be unenforceable or would make installing a theme a migration.
|
||||
*
|
||||
* The consequence is deliberate: a star can outlive its theme. That is why the reader intersects with the
|
||||
* live registry rather than trusting the table — a favourite naming a theme nobody has installed is
|
||||
* ignored, not shown as a broken entry, and it costs one row until the account unstars something.
|
||||
*/
|
||||
export const themeFavorites = pgTable(
|
||||
'theme_favorites',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The theme's registry id — `duck-dark`, `catppuccin`. Not a foreign key; see above. */
|
||||
themeId: text('theme_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
// See src/databases/CLAUDE.md → "Composite keys".
|
||||
(table) => [uniqueIndex('uq_theme_favorites_user_theme').on(table.userId, table.themeId)],
|
||||
);
|
||||
+70
-16
@@ -16,6 +16,7 @@ import { resolveAuthToken } from './servers/auth-token';
|
||||
import { isWsProviderAllowed } from './servers/permissions/authorize';
|
||||
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||
import { minesweeperWebsocket } from './servers/api/minesweeper/websocket';
|
||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
|
||||
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
|
||||
@@ -159,19 +160,65 @@ const handlers: Record<string, any> = {
|
||||
'chat-notify': chatNotifyWebsocket,
|
||||
'task-runner': taskRunnerWebsocket,
|
||||
pipeline: pipelineWebsocket,
|
||||
minesweeper: minesweeperWebsocket,
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
// Both doors into the platform, checked against the permission registry before either opens.
|
||||
//
|
||||
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
||||
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
||||
// from the map directly above, so neither list can be a stale copy of the thing it describes.
|
||||
/**
|
||||
* Every websocket path Bun serves, and the provider each one upgrades to.
|
||||
*
|
||||
* ── Why this exists rather than a hand-written route table ──
|
||||
*
|
||||
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
|
||||
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
|
||||
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
|
||||
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
|
||||
*
|
||||
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
|
||||
* days after their handlers and registry entries were commented out — not exploitable, since
|
||||
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
|
||||
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
|
||||
* shape.
|
||||
*
|
||||
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
|
||||
* handler or without a registry claim fails the boot rather than serving quietly.
|
||||
*/
|
||||
const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/api/tasks/run/ws': 'task-runner',
|
||||
'/api/tasks/pipeline/ws': 'pipeline',
|
||||
'/api/terminal/ws': 'terminal',
|
||||
'/api/minesweeper/ws': 'minesweeper',
|
||||
'/api/chat/ws': 'chat',
|
||||
'/api/chat/notify/ws': 'chat-notify',
|
||||
};
|
||||
|
||||
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
||||
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
||||
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
|
||||
|
||||
/*
|
||||
Both doors into the platform, checked against the permission registry before either opens.
|
||||
|
||||
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
|
||||
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
|
||||
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
|
||||
parallel list that agrees with it by hand.
|
||||
|
||||
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
|
||||
failure this catches is the other one — a route reachable with nothing claiming it.
|
||||
*/
|
||||
assertPermissionTotality({
|
||||
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
||||
wsProviders: Object.keys(handlers),
|
||||
wsProviders: SERVED_WS_PROVIDERS,
|
||||
});
|
||||
|
||||
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
|
||||
// into a refusal to boot with the name in it.
|
||||
for (const [path, provider] of Object.entries(WS_ROUTES)) {
|
||||
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
|
||||
}
|
||||
|
||||
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
|
||||
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
|
||||
// module whose only job is a side effect is one an import sweep quietly deletes.
|
||||
@@ -312,11 +359,16 @@ console.log(
|
||||
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
|
||||
);
|
||||
|
||||
async function upgradeWs(
|
||||
req: Request,
|
||||
server: any,
|
||||
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
|
||||
) {
|
||||
/**
|
||||
* A provider reachable by upgrading a URL.
|
||||
*
|
||||
* Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
|
||||
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
||||
* deliberately absent — they upgrade through their own handlers, not through here.
|
||||
*/
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'minesweeper';
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
@@ -441,11 +493,13 @@ const server = serve({
|
||||
});
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||
},
|
||||
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
||||
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
||||
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
|
||||
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
|
||||
...Object.fromEntries(
|
||||
Object.entries(WS_ROUTES).map(([path, provider]) => [
|
||||
path,
|
||||
(req: Request, server: any) => upgradeWs(req, server, provider),
|
||||
]),
|
||||
),
|
||||
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
||||
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
||||
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession } from './websocket';
|
||||
import { emitToSession, sessionKeyForTranscript } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getChatNotifyPreference,
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
forgetChatSessions,
|
||||
} from 'officerdb';
|
||||
import type { NotifyPreference } from 'officerdb/types';
|
||||
import { setNudge, getNudge, cancelNudge } from 'officerdb';
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
stopBackgroundTask,
|
||||
claudeSessionContext,
|
||||
liveSessionTitle,
|
||||
isCommandersDir,
|
||||
} from './claude-sessions';
|
||||
import {
|
||||
listOpenCodeSessions,
|
||||
@@ -58,7 +58,7 @@ import { readSttConfig } from '../server-settings/stt';
|
||||
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
|
||||
* used to stand at the top of this router.
|
||||
*/
|
||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
export async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
const resolved = await resolveHomeDir(user.id);
|
||||
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
||||
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
|
||||
@@ -151,6 +151,12 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
|
||||
Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole
|
||||
conversation list down with it, the same posture `getChatSessionModels` below already takes.
|
||||
|
||||
This route does NOT report `isCommander` on its rows, and that is not an omission. It used to: rows were
|
||||
tagged from this same set a few lines above and then removed by it here, so the flag was false on every
|
||||
row that survived — a field that could only ever lie, plus a second identical query to produce it. A list
|
||||
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
|
||||
flag is real, because that route keeps both kinds.
|
||||
*/
|
||||
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
|
||||
const sessions = merged.filter((s) => !filed.has(s.id));
|
||||
@@ -257,6 +263,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
offset: windowed ? start : 0,
|
||||
title: context?.title ?? null,
|
||||
partCount: context?.partCount ?? 1,
|
||||
/*
|
||||
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
|
||||
|
||||
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
|
||||
decided whether to by looking at the route it was rendered on. So the same commander, opened from
|
||||
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
|
||||
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,6 +298,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
sidecar.listLiveClaudeSessions(user.id),
|
||||
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
||||
]);
|
||||
const commanderIds = await listCommandHqSessionIds(user.id);
|
||||
const sessions = live.map((session) => {
|
||||
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
||||
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||
@@ -294,14 +311,16 @@ chatRouter.get('/live', async (ctx) => {
|
||||
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.
|
||||
// From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
|
||||
// directory therefore answers nothing. One query for the whole set, before the map below.
|
||||
//
|
||||
// A commander mid-first-turn has no row yet and so reports false here for one turn. That is the same
|
||||
// turn on which it must not arm anything, and it corrects itself the moment it is filed.
|
||||
//
|
||||
// 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,
|
||||
isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -347,7 +366,42 @@ chatRouter.get('/live', async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
|
||||
const rows = [...sessions, ...openCodeSessions];
|
||||
|
||||
/*
|
||||
`?kind=` — the split, done here rather than by each client.
|
||||
|
||||
The classification was already server-side; what was not was the FILTER, so every client repeated it.
|
||||
That is fine for the web list, which has both tabs on screen, and wrong for a phone that swipes between
|
||||
live chats one at a time and must simply never be handed a commander.
|
||||
|
||||
**The default is `agent`, not "everything".** A caller that says nothing gets ordinary chats, so the way
|
||||
to receive a commander is to ask for one by name. That is the whole point: forgetting the parameter can
|
||||
only ever under-report, never put a deploy commander into a swipe order that has no way to skip it. It
|
||||
costs the two callers that DO want both an explicit `kind=all`, which is the right trade — they are few,
|
||||
they are on screens built for it, and they are the ones in a position to notice if it is wrong.
|
||||
|
||||
Counts always describe the WHOLE set, whatever the filter. The web tabs show `Agents (n) /
|
||||
Commanders (n)` side by side and both numbers have to be right whichever tab is open — a filter that
|
||||
also hid the other count would force a second request to render one screen, which is how a server-side
|
||||
filter ends up costing more than the client-side one it replaced.
|
||||
|
||||
An unrecognised `kind` is a 400, never a silent fallback. Plural forms are accepted because the client's
|
||||
own type is already `'agents' | 'commanders'`, and the failure silence would buy is precise: a mistyped
|
||||
`?kind=agents` resolving to something other than agents is the exact bug this exists to prevent.
|
||||
*/
|
||||
const kind = ctx.req.query('kind') ?? 'agent';
|
||||
const counts = {
|
||||
agents: rows.filter((s) => !s.isCommander).length,
|
||||
commanders: rows.filter((s) => s.isCommander).length,
|
||||
};
|
||||
if (!['agent', 'agents', 'commander', 'commanders', 'all'].includes(kind)) {
|
||||
return ctx.json({ error: `Unknown kind '${kind}' — expected 'agent', 'commander' or 'all'` }, 400);
|
||||
}
|
||||
const wantCommanders = kind === 'commander' || kind === 'commanders';
|
||||
const shown = kind === 'all' ? rows : rows.filter((s) => s.isCommander === wantCommanders);
|
||||
|
||||
return ctx.json({ sessions: shown, counts });
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
|
||||
@@ -358,8 +412,36 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
if (isOpenCodeSessionId(id)) {
|
||||
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
/*
|
||||
Stop the harness BEFORE removing its file, and wait for it.
|
||||
|
||||
The `claude` CLI owns the transcript. Deleting it while a live session is still writing removes a file
|
||||
that is written straight back, so the conversation reappears untitled on the next refresh — see
|
||||
`killClaudeAndWait`. No live session means nothing to wait for, which is the ordinary case here.
|
||||
*/
|
||||
const liveKey = sessionKeyForTranscript(id);
|
||||
if (liveKey) await sidecar.killClaudeAndWait(liveKey, ctx.get('user').id);
|
||||
|
||||
// `.length`, not truthiness: this returns the IDS it removed and an empty array is truthy in JavaScript,
|
||||
// so `if (removed)` would report a delete that found nothing as a success.
|
||||
const removed = deleteClaudeSession(who, cwd, id);
|
||||
if (!removed.length) return ctx.text('Not found', 404);
|
||||
|
||||
/*
|
||||
The transcript is gone; forget what the platform stored ABOUT it. Four tables describe a conversation with
|
||||
no foreign key — there is no row for one to reference — so nothing cascades and this has to be explicit.
|
||||
One of them, `nudges`, is a live schedule: left behind it keeps firing at a conversation that is gone.
|
||||
|
||||
Two id spaces, and they are not interchangeable: three tables are keyed on the transcript id, while
|
||||
`chat_session_events` is keyed on officer's session key. Undefined here is the normal case for an old
|
||||
conversation — no live session holds it, so nothing is keyed on a key to remove.
|
||||
*/
|
||||
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { DATA_PATH, getCommandersDir } from '../../data-path';
|
||||
import {
|
||||
appendTextAs,
|
||||
@@ -642,9 +642,12 @@ const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): stri
|
||||
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
|
||||
* part of several.
|
||||
*/
|
||||
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
|
||||
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): string[] {
|
||||
const filePath = findTranscript(who, cwd, sessionId);
|
||||
if (!filePath) return false;
|
||||
// The IDS it removed, not a boolean — empty means nothing was there. The caller needs them to forget the
|
||||
// rows keyed on each one (`forgetChatSessions`), and a `/clear` chain is several transcripts presenting as
|
||||
// one conversation: returning true would have told a caller to forget the head and orphan its parts.
|
||||
if (!filePath) return [];
|
||||
|
||||
const ownCwd = firstCwd(who.osUser, filePath);
|
||||
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
|
||||
@@ -653,7 +656,7 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
|
||||
// success — the conversation reappeared on the next refresh.
|
||||
const dir = dirname(filePath);
|
||||
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
|
||||
return true;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,20 +987,45 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||
return applyLineage(sessions);
|
||||
}
|
||||
|
||||
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
||||
/**
|
||||
* Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain.
|
||||
*
|
||||
* **Says nothing about commanders**, deliberately. It used to: every commander shared one directory, so the
|
||||
* directory was the answer and this computed it once per group. That stopped being true the moment a
|
||||
* commander could choose its own working directory — a commander in the platform checkout would have been
|
||||
* reported as an ordinary chat, and an ordinary chat in the old commanders directory as a commander.
|
||||
*
|
||||
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
|
||||
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
|
||||
* directory scan.
|
||||
*/
|
||||
/**
|
||||
* A transcript with no human or assistant turn in it is not a conversation.
|
||||
*
|
||||
* The harness writes metadata records — `last-prompt`, `ai-title`, `atis-latch` — into a transcript file
|
||||
* independently of the conversation, and it will happily create the file to hold them. So a session that was
|
||||
* deleted, or one that never sent anything, leaves a file containing only those three lines, which listed as
|
||||
* "(untitled) · 0 msgs" and accumulated: four of them from one evening of testing.
|
||||
*
|
||||
* Filtered at the list rather than deleted on disk. The files are the harness's, it is entitled to write
|
||||
* them, and a cleanup that raced it would be the same class of bug as the one that produced them.
|
||||
*/
|
||||
const hasConversation = (s: ClaudeSessionSummary): boolean => s.messageCount > 0;
|
||||
|
||||
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))
|
||||
.filter(hasConversation)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this directory the one every commander shares?
|
||||
* Is this the default commanders directory?
|
||||
*
|
||||
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
|
||||
* a commander now chooses its own working directory. This remains only for the places that legitimately care
|
||||
* about that specific path: creating it for a new OS account, and as the historical location of commanders
|
||||
* created before the cwd was choosable.
|
||||
*
|
||||
* 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
|
||||
@@ -1011,6 +1039,36 @@ export function isCommandersDir(email: string, cwd: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-shaped summary, found by id wherever it lives.
|
||||
*
|
||||
* For callers that hold a set of ids rather than a directory — Command HQ, which reads its commanders from
|
||||
* `command_hq_sessions` and cannot scan for them any more now that each chooses its own working directory.
|
||||
*
|
||||
* Returns null when no transcript can be found, which is the same signal a directory scan gave by omission:
|
||||
* a filed row whose conversation has been deleted. `purgeCommandHqSessions` is what tidies those.
|
||||
*
|
||||
* Deliberately NOT chain-aware in the way `listClaudeSessions` is. It reports the transcript addressed by
|
||||
* this id; a `/clear` chain's head is what gets filed and what every surface addresses, so the tail is not
|
||||
* something a caller here holds an id for.
|
||||
*/
|
||||
export function loadClaudeSessionSummaryById(who: ChatIdentity, sessionId: string): ClaudeSessionSummary | null {
|
||||
const filePath = locateTranscript(who, sessionId);
|
||||
if (!filePath) return null;
|
||||
|
||||
// `mtimeMs` becomes `updatedAt`, which is what every caller sorts on — so it has to be real, not zero.
|
||||
// Taken from `listTranscriptsAs` over the transcript's OWN directory rather than a bare `statSync`: the
|
||||
// file may belong to a member, and reading a member's tree needs the as-user path. One directory listing
|
||||
// per commander, scoped to one group — the same call the old whole-directory scan made, once per row
|
||||
// instead of once for all of them, which is the cost of letting each choose where it lives.
|
||||
const group = basename(dirname(filePath));
|
||||
const entry = listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), group).find((f) => f.id === sessionId);
|
||||
if (!entry) return null;
|
||||
|
||||
const summary = summarizeTranscript(who.osUser, filePath, sessionId, entry.mtimeMs);
|
||||
return summary ? publish(summary) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every conversation in every directory, newest first.
|
||||
*
|
||||
|
||||
@@ -78,11 +78,15 @@ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
||||
/**
|
||||
* How long a reading is reused.
|
||||
*
|
||||
* Both halves are expensive in different ways — the endpoint rate-limits, and the scan reads ~90MB of JSONL —
|
||||
* and neither changes meaningfully inside a minute. Cached SEPARATELY so a network failure does not throw
|
||||
* away a good scan, and so opening and shutting the popover is not a request per flick.
|
||||
* The transcript scan reads ~90MB of JSONL and its answer does not change meaningfully inside a minute, so
|
||||
* it is still served from cache. It is also the same answer whichever account is signed in — it counts what
|
||||
* this machine wrote, not what an account is allowed — which is why switching account does not need to
|
||||
* invalidate it.
|
||||
*
|
||||
* **The limits have no TTL and are never served from cache.** They are per-account and they are the number
|
||||
* somebody opens this panel to read, so a stale one is worse than a slow one. The store is kept for the
|
||||
* failure path only — see `lastGoodLimits`.
|
||||
*/
|
||||
const LIMITS_TTL_MS = 60_000;
|
||||
const SCAN_TTL_MS = 60_000;
|
||||
|
||||
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude');
|
||||
@@ -142,7 +146,21 @@ export async function readAccountEmail(): Promise<string | null> {
|
||||
|
||||
type LimitsResult = { limits: UsageLimit[]; problem?: string };
|
||||
|
||||
let limitsCache: { at: number; result: LimitsResult } | null = null;
|
||||
/*
|
||||
Last good reading PER ACCOUNT, and used only when a probe fails.
|
||||
|
||||
This was a TTL cache keyed on nothing, and the consequence was the whole feature lying. Switching account
|
||||
changes which credential `readOauthLogin` returns, but a cached entry was served regardless — so the panel
|
||||
showed the previous account's percentages under the new account's name for the length of the TTL, and the
|
||||
failure fallback served them for as long as the probe kept failing. Reported by the owner: maxed out on one
|
||||
account, switched, and the numbers did not move.
|
||||
|
||||
So there is no TTL any more. Every request probes, because these percentages are the entire point of the
|
||||
panel and a stale one is worse than a slow one. What survives is the fallback, now keyed on the account it
|
||||
was measured for, so a flaky request degrades to that account's own last reading rather than to a blank
|
||||
panel or — far worse — to somebody else's numbers.
|
||||
*/
|
||||
const lastGoodLimits = new Map<string, { at: number; result: LimitsResult }>();
|
||||
|
||||
const asPercent = (value: unknown): number => {
|
||||
const n = Number(
|
||||
@@ -303,20 +321,22 @@ async function collectLimits(): Promise<LimitsResult & { plan: string | null; me
|
||||
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
|
||||
}
|
||||
|
||||
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
|
||||
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
|
||||
}
|
||||
// Which account this reading belongs to. Null would make every account share one entry, which is the bug
|
||||
// this key exists to prevent, so an unattributable reading gets no fallback rather than a shared one.
|
||||
const account = await readAccountEmail();
|
||||
|
||||
const fresh = await probeLimits(login.accessToken);
|
||||
if (fresh.limits.length) {
|
||||
limitsCache = { at: now, result: fresh };
|
||||
if (account) lastGoodLimits.set(account, { at: now, result: fresh });
|
||||
return { ...fresh, plan: login.plan, measuredAt: now };
|
||||
}
|
||||
|
||||
// A failed probe falls back to the last good reading rather than to nothing — the numbers are minutes old
|
||||
// at worst, and a panel that goes blank on one flaky request is worse than one that is slightly behind.
|
||||
const stale = limitsCache?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
|
||||
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
|
||||
// A failed probe falls back to THIS account's last good reading rather than to nothing — minutes old at
|
||||
// worst, and a panel that blanks on one flaky request is worse than one slightly behind. Windows that have
|
||||
// since reset are dropped: a stale 78% would misreport an allowance that is now untouched.
|
||||
const previous = account ? lastGoodLimits.get(account) : undefined;
|
||||
const stale = previous?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
|
||||
return { limits: stale, plan: login.plan, measuredAt: previous?.at ?? now, problem: fresh.problem };
|
||||
}
|
||||
|
||||
// ── Local transcript scan ──
|
||||
|
||||
@@ -76,6 +76,20 @@ export type ClientMessage =
|
||||
* first turn is the first moment the preference can be acted on OR written down.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* The name the owner chose for a COMMANDER before its first message.
|
||||
*
|
||||
* Claude Code will not accept a name for a new session — the harness names its own from the first
|
||||
* prompt — so the platform renames the transcript the moment it exists. Carried on the turn rather
|
||||
* than sent afterwards because it must not depend on a browser still being open: the name was chosen
|
||||
* before anything was said, and losing it to a closed tab would leave a commander called after
|
||||
* whatever it happened to say first.
|
||||
*
|
||||
* Applied once, by `fileAsCommander`, via `renameClaudeSession` — which writes a `summary` record and
|
||||
* therefore an EXPLICIT title, and an explicit title is never overwritten by `ai-title` or by the
|
||||
* first-message fallback.
|
||||
*/
|
||||
commanderName?: string;
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
|
||||
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
|
||||
|
||||
@@ -15,12 +15,7 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
getOwnerHomeDir,
|
||||
getEmailAccountsDir,
|
||||
findEmailAccountName,
|
||||
getCommandersDir,
|
||||
} from '../../../servers/data-path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
|
||||
import { attachedFilePaths } from './attached-files';
|
||||
import {
|
||||
getUserSettings,
|
||||
@@ -37,6 +32,8 @@ import {
|
||||
} from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
||||
import { chatIdentity } from './chat';
|
||||
import { commanderBlock } from '../command-hq/commander-block';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
@@ -154,16 +151,25 @@ async function resolveChatCwd(
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
// Every commander a user has shares ONE directory — never a client-chosen one, and never per-commander
|
||||
// (see `getCommandersDir`). `msg.cwd` is ignored on this branch entirely, the same posture the identity
|
||||
// half of a turn already takes: resolved by the platform, never trusted from the caller.
|
||||
//
|
||||
// `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern
|
||||
// `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory
|
||||
// is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true`
|
||||
// makes every call after that a no-op stat.
|
||||
/*
|
||||
A commander runs where the owner chose, and the choice is required.
|
||||
|
||||
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
|
||||
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
|
||||
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
|
||||
works on the platform can live in the platform checkout, where its relative paths mean something and its
|
||||
transcripts sit beside the work they are about.
|
||||
|
||||
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
|
||||
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
|
||||
the account's own tree by a crafted message.
|
||||
|
||||
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
|
||||
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
|
||||
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
|
||||
*/
|
||||
if (msg.context === 'command-hq') {
|
||||
const dir = getCommandersDir(email);
|
||||
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
@@ -351,6 +357,15 @@ function createMessageHandler(
|
||||
resumeSessionId?: string,
|
||||
planMode?: boolean,
|
||||
commandHq?: boolean,
|
||||
/**
|
||||
* The name the owner chose before the conversation started, for a commander.
|
||||
*
|
||||
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
|
||||
* renames it the moment the transcript exists. Done here rather than by the client because it must not
|
||||
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
|
||||
* closed tab would leave a commander called after whatever it happened to say first.
|
||||
*/
|
||||
commanderName?: string,
|
||||
) {
|
||||
/*
|
||||
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
||||
@@ -450,17 +465,54 @@ function createMessageHandler(
|
||||
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
|
||||
reason a chat turn fails.
|
||||
*/
|
||||
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
|
||||
// the retry note inside `fileAsCommander`.
|
||||
let commanderNamed = false;
|
||||
|
||||
const fileAsCommander = (): void => {
|
||||
if (!commandHq || !transcriptId) return;
|
||||
const userId = sessionManager.getSession(sessionId)?.userId;
|
||||
if (!userId) return;
|
||||
void createCommandHqSession(userId, transcriptId).catch((err: unknown) => {
|
||||
logger.warn('Could not file this session as a commander', {
|
||||
sessionId,
|
||||
transcriptId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
const filedId = transcriptId;
|
||||
// The name goes in with the row, not only to the transcript: the list reads it from here and is
|
||||
// right immediately, rather than waiting on a summary record the harness cannot write until the
|
||||
// first turn has finished.
|
||||
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
|
||||
.then(async () => {
|
||||
/*
|
||||
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
|
||||
|
||||
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
|
||||
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
|
||||
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
|
||||
kept the harness's own `ai-title` — which is exactly what the first live test showed.
|
||||
|
||||
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
|
||||
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
|
||||
|
||||
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
|
||||
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
|
||||
*/
|
||||
if (!commanderName?.trim() || commanderNamed) return;
|
||||
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
|
||||
// decides the home and the OS user, and a rename writes a file as that user.
|
||||
const user = await getUserById(userId).catch(() => null);
|
||||
if (!user?.email) return;
|
||||
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
|
||||
if (!who) return;
|
||||
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
|
||||
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
|
||||
// and locates the transcript wherever it actually is — so this works either way, and passing a
|
||||
// path we know is probably wrong would be the kind of thing that reads as intent later.
|
||||
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.warn('Could not file this session as a commander', {
|
||||
sessionId,
|
||||
transcriptId: filedId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
@@ -511,6 +563,8 @@ function createMessageHandler(
|
||||
// written on its very first turn rather than on its second.
|
||||
if (msg.type === 'session:claude' && msg.claudeSessionId) {
|
||||
transcriptId = msg.claudeSessionId;
|
||||
// The platform's own copy of the mapping — see `transcriptIdBySessionKey`.
|
||||
transcriptIdBySessionKey.set(sessionId, msg.claudeSessionId);
|
||||
record();
|
||||
fileAsCommander();
|
||||
}
|
||||
@@ -519,6 +573,9 @@ function createMessageHandler(
|
||||
record();
|
||||
fileAsCommander();
|
||||
}
|
||||
// And retried on every result until the name lands. The first attempt runs before the transcript file
|
||||
// exists, so the end of the first turn is the earliest a rename can succeed.
|
||||
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
|
||||
|
||||
/*
|
||||
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
|
||||
@@ -734,6 +791,11 @@ async function handleChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
@@ -851,6 +913,32 @@ export type ClaudeTurnOutcome = { ok: true; transcriptId: string } | { ok: false
|
||||
* generic timeout: the reasons below (member not signed in, Claude not installed, spawn failure) are the
|
||||
* same reasons a websocket caller already sees via `sendToClient`, just mirrored to a second listener.
|
||||
*/
|
||||
/**
|
||||
* Officer's session key → Claude's transcript id, for the platform's own use.
|
||||
*
|
||||
* The sidecar keeps this mapping and the platform cannot read it: `state.ts` holds it in the sidecar's
|
||||
* memory, and re-loading its file from here does not find a live session. So the platform keeps its own,
|
||||
* filled from the `session:claude` message it already handles.
|
||||
*
|
||||
* This is the third distinct place the two ids have caused a bug today. The mapping is not derivable — it is
|
||||
* announced, once, mid-turn — so anything needing it either records it when it passes or does without.
|
||||
*/
|
||||
const transcriptIdBySessionKey = new Map<string, string>();
|
||||
|
||||
/** The mapping read backwards, for callers holding a transcript id — the delete route, mainly. */
|
||||
export function sessionKeyForTranscript(transcriptId: string): string | undefined {
|
||||
for (const [key, id] of transcriptIdBySessionKey) if (id === transcriptId) return key;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions already told what they are and where to record it.
|
||||
*
|
||||
* In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one
|
||||
* turn and is the safe direction — a commander that was never told is one that cannot be stopped.
|
||||
*/
|
||||
const briefedSessions = new Set<string>();
|
||||
|
||||
export async function startClaudeTurn(params: {
|
||||
email: string;
|
||||
username: string;
|
||||
@@ -861,6 +949,11 @@ export async function startClaudeTurn(params: {
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -869,12 +962,44 @@ export async function startClaudeTurn(params: {
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
|
||||
commanderName?: string;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
|
||||
}): Promise<void> {
|
||||
const { email, username, userId, ws, sessionId, model, msg, effectivePrompt, onOutcome } = params;
|
||||
const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params;
|
||||
|
||||
/*
|
||||
A commander is told its record paths ON A TURN, not in the system preamble.
|
||||
|
||||
The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a
|
||||
commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row
|
||||
and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session
|
||||
and never rebuilds the preamble. So a commander never received the block at all, and its loop had no
|
||||
paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no
|
||||
`loop-pid`.
|
||||
|
||||
Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript
|
||||
shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches
|
||||
the agent without appearing in the conversation.
|
||||
|
||||
Once per session, on the first turn where the block resolves, which is the first turn after the commander
|
||||
is filed. It stays available afterwards because it is in the conversation's own history from then on.
|
||||
*/
|
||||
let effectivePrompt = params.effectivePrompt;
|
||||
if (!briefedSessions.has(sessionId)) {
|
||||
const block = await commanderBlock({
|
||||
userId,
|
||||
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
|
||||
selfStarted: msg.selfStarted === true,
|
||||
});
|
||||
if (block) {
|
||||
briefedSessions.add(sessionId);
|
||||
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
|
||||
}
|
||||
}
|
||||
const fail = (error: string) => {
|
||||
sendToClient(ws, { type: 'error', message: error });
|
||||
onOutcome?.({ ok: false, error });
|
||||
@@ -958,6 +1083,7 @@ export async function startClaudeTurn(params: {
|
||||
msg.resumeSessionId,
|
||||
msg.planMode,
|
||||
msg.context === 'command-hq',
|
||||
msg.commanderName,
|
||||
);
|
||||
// Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the
|
||||
// exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same
|
||||
@@ -1035,6 +1161,11 @@ async function handleClaudeCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -1058,6 +1189,11 @@ async function handleOpenCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -1263,13 +1399,20 @@ function handlePlanDecision(
|
||||
): void {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (!sessionId) return;
|
||||
sidecar.decideClaudePlan({
|
||||
const delivered = sidecar.decideClaudePlan({
|
||||
sessionKey: sessionId,
|
||||
userId: ws.data.userId,
|
||||
requestId: msg.requestId,
|
||||
approve: msg.approve,
|
||||
feedback: msg.feedback,
|
||||
});
|
||||
// Same drop as `handleQuestionAnswer`, same spinning card — see `sendFire`.
|
||||
if (!delivered) {
|
||||
sendToClient(ws, {
|
||||
type: 'error',
|
||||
message: 'The agent is not connected right now, so that decision did not reach it. Try again in a moment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1283,12 +1426,29 @@ function handlePlanDecision(
|
||||
function handleQuestionAnswer(ws: ServerWebSocket<WSData>, msg: { toolCallId: string; answer: string }): void {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (!sessionId) return;
|
||||
sidecar.answerClaudeQuestion({
|
||||
/*
|
||||
A dropped answer has to reach the person who sent it.
|
||||
|
||||
`sendFire` does nothing when no sidecar is registered, and that window is real — every restart of this
|
||||
process has one while the agent reconnects. The answer vanished with no log and no error, so the
|
||||
question card spun on, which is indistinguishable from a slow agent. The only move left was to send
|
||||
another message, which is the worst possible thing to do to a waiting tool call.
|
||||
|
||||
The agent's own handler already reports the failures it CAN see (no such session, question no longer
|
||||
waiting). This is the one it cannot, because the command never arrives.
|
||||
*/
|
||||
const delivered = sidecar.answerClaudeQuestion({
|
||||
sessionKey: sessionId,
|
||||
userId: ws.data.userId,
|
||||
toolCallId: msg.toolCallId,
|
||||
answer: msg.answer,
|
||||
});
|
||||
if (!delivered) {
|
||||
sendToClient(ws, {
|
||||
type: 'error',
|
||||
message: 'The agent is not connected right now, so that answer did not reach it. Try again in a moment.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getCommandHqSession, getUserById } from 'officerdb';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
What a commander is told about ITSELF, once per session.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
|
||||
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
|
||||
background-job dialog composes. Which was fine while the only way to arm a commander was through that
|
||||
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
|
||||
starting its own background job never sees that preamble, so it would record nothing, and delete would go
|
||||
back to leaving orphaned loops without anybody noticing it had.
|
||||
|
||||
── Why the paths come from here and the method comes from the skill ──
|
||||
|
||||
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
|
||||
material that belongs with everything else about commanders, in the skill, where it can be as long as it
|
||||
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
|
||||
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
|
||||
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
|
||||
exactly what an always-loaded block is for.
|
||||
|
||||
Short for the same reason every affordance is short: it is charged to every turn of every commander.
|
||||
*/
|
||||
|
||||
type CommanderBlockParams = {
|
||||
/** The Officer account, for the row lookup and for the paths. */
|
||||
userId: number;
|
||||
/** Claude's transcript id — what a commander is keyed on everywhere. */
|
||||
sessionId: string | null;
|
||||
/**
|
||||
* The owner pressed Start rather than typing an opening message.
|
||||
*
|
||||
* They supplied a name and a working directory and nothing else, so there is no request to act on and
|
||||
* the commander has to run the interview itself. Only ever true on the turn the button sends — a
|
||||
* commander asked something later is an ordinary conversation and must not restart its intake.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
};
|
||||
|
||||
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
|
||||
if (!params.sessionId) return null;
|
||||
|
||||
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
|
||||
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
|
||||
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
|
||||
if (!row) return null;
|
||||
|
||||
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
|
||||
// session spawn, alongside the affordances and the skills index, which are also read here rather than
|
||||
// cached — the same trade for the same reason.
|
||||
const user = await getUserById(params.userId).catch(() => null);
|
||||
if (!user?.email) return null;
|
||||
const email = user.email;
|
||||
|
||||
return [
|
||||
'# You are a commander',
|
||||
'',
|
||||
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
|
||||
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
|
||||
'establish with the owner first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
|
||||
'',
|
||||
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
|
||||
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
|
||||
'',
|
||||
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves your loop running.',
|
||||
'',
|
||||
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
|
||||
'that is what their absence means. Ask, and arm once it is agreed.',
|
||||
/*
|
||||
The button case. A pointer, not the interview itself: the questions live in the skill, which is
|
||||
re-read from disk on every run, so rewording one costs nothing. The same words here would cost a
|
||||
`pm2 restart officer` every time — and this file is the wrong home for doctrine that will be
|
||||
edited far more often than the mechanism around it.
|
||||
*/
|
||||
...(params.selfStarted
|
||||
? [
|
||||
'',
|
||||
'**Nobody has told you what they want yet.** They gave a name and a working directory and',
|
||||
'pressed Start, which is a request to run the interview — not a request to do anything else.',
|
||||
'Read the **Commanders** skill now and follow "When you started yourself": orient in this',
|
||||
'directory first, then open with what you found and ONE question.',
|
||||
]
|
||||
: []),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -5,11 +5,23 @@ import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { getChatEventsSince } from 'officerdb';
|
||||
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import {
|
||||
clearSpawnedRecord,
|
||||
listSpawnedSessions,
|
||||
loopPidPath,
|
||||
spawnedRecordPath,
|
||||
stopCommanderProcesses,
|
||||
liveLoopPid,
|
||||
} from './spawned';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
loadClaudeSessionSummaryById,
|
||||
renameClaudeSession,
|
||||
deleteClaudeSession,
|
||||
type ChatIdentity,
|
||||
@@ -55,11 +67,17 @@ import {
|
||||
*
|
||||
* ── Where commanders live ──
|
||||
*
|
||||
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per
|
||||
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function
|
||||
* `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a
|
||||
* `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same
|
||||
* transcript reader `/chat` already is.
|
||||
* **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
|
||||
* directory is where its relative paths resolve and where its transcripts are grouped.
|
||||
*
|
||||
* It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only
|
||||
* way to tell a commander from an ordinary chat. Identity is the `command_hq_sessions` row now, so the
|
||||
* directory became free to be useful, and a commander that works on the platform can live in the platform.
|
||||
*
|
||||
* The consequence for this router is that listing is no longer a directory scan. It reads its rows and loads
|
||||
* each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk. A row
|
||||
* whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in the old scan
|
||||
* was, and `POST /purge` is what tidies those.
|
||||
*/
|
||||
|
||||
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
@@ -194,12 +212,24 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const categoryParam = ctx.req.query('category');
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
const [rows, live] = await Promise.all([
|
||||
listCommandHqSessions(user.id, categoryId),
|
||||
listClaudeSessions(who, getCommandersDir(who.email)),
|
||||
]);
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory.
|
||||
|
||||
const liveById = new Map(live.map((s) => [s.id, s]));
|
||||
It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so
|
||||
one scan found them all and a row without a match there was a row whose transcript had been deleted. A
|
||||
commander chooses its own working directory now, so that scan finds only the ones that happen to still
|
||||
live in the old shared folder and would silently hide every other.
|
||||
|
||||
One `locateTranscript` per row instead of one scan for all of them. Commanders are few — this is a
|
||||
handful of stats, not a directory walk per row — and a row whose transcript cannot be found anywhere is
|
||||
still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const summaries = rows
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId))
|
||||
.filter((s): s is NonNullable<typeof s> => !!s);
|
||||
|
||||
const liveById = new Map(summaries.map((s) => [s.id, s]));
|
||||
const filed = rows.filter((row) => liveById.has(row.sessionId));
|
||||
|
||||
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
|
||||
@@ -219,10 +249,17 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is filed, while the
|
||||
// summary record cannot exist until the first turn ends — so until then the transcript still reports
|
||||
// the opening brief, and the row is the only source that is right from the start.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
watcherArmed: row.watcherArmed,
|
||||
// The pid file is the FACT; `watcher_armed` is a claim only one path sets. A commander that started
|
||||
// its loop as a plain background job recorded its pid correctly and still reported unarmed, so the
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -265,8 +302,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = await ctx.req
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null }>()
|
||||
.catch(() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null });
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>()
|
||||
.catch(
|
||||
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
|
||||
);
|
||||
const prompt = body.prompt?.trim();
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
|
||||
@@ -302,7 +341,17 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
ws: null,
|
||||
sessionId,
|
||||
model,
|
||||
msg: { prompt, displayText: body.displayText?.trim() || undefined, context: 'command-hq' },
|
||||
msg: {
|
||||
prompt,
|
||||
displayText: body.displayText?.trim() || undefined,
|
||||
context: 'command-hq',
|
||||
// Where this commander runs, and its name. Both are chosen before the first message — the UI makes
|
||||
// them mandatory — so this programmatic path takes them too rather than inventing a default. An
|
||||
// absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort
|
||||
// for a caller that predates the requirement.
|
||||
...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}),
|
||||
...(body.name?.trim() ? { commanderName: body.name.trim() } : {}),
|
||||
},
|
||||
effectivePrompt: prompt,
|
||||
onOutcome: settle,
|
||||
});
|
||||
@@ -322,7 +371,9 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: getCommandersDir(user.email) });
|
||||
// The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is
|
||||
// no longer where a commander necessarily lives.
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -350,10 +401,11 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
]);
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -417,8 +469,10 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const sessionId = body.sessionId?.trim();
|
||||
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
|
||||
|
||||
const live = listClaudeSessions(who, getCommandersDir(who.email));
|
||||
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory');
|
||||
// Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
|
||||
// its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and
|
||||
// as a GUARD it only ever established that the transcript exists, which this still does.
|
||||
if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation');
|
||||
|
||||
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
|
||||
return ctx.json(row);
|
||||
@@ -451,7 +505,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
|
||||
// `who.home` as the HINT only — `findTranscript` locates the transcript wherever it actually is, and a
|
||||
// commander's directory is its own choice now.
|
||||
const ok = renameClaudeSession(who, who.home, sessionId, body.name.trim());
|
||||
if (!ok) throw errors.NOT_FOUND('transcript not found');
|
||||
}
|
||||
if (body.watcherArmed === false) {
|
||||
@@ -469,11 +525,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
||||
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
||||
*
|
||||
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
|
||||
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to
|
||||
* update once this returns, and the running background process (if any) dies on its own the same way it
|
||||
* would if the harness process were killed out from under any other conversation — not a new failure
|
||||
* mode this route has to account for.
|
||||
* ── It cascades, and it has to ──
|
||||
*
|
||||
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
|
||||
*
|
||||
* 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
|
||||
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
|
||||
* process takes all of it. The comment here used to claim the background process "dies on its own" —
|
||||
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
|
||||
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
|
||||
* row left to disarm.
|
||||
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
|
||||
* — see `spawned.ts` for the three approaches that do not work.
|
||||
* 3. DELETE the commander's own transcript and row, as before.
|
||||
*
|
||||
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
|
||||
* could spawn a new one between steps 2 and 3.
|
||||
*
|
||||
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
|
||||
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
|
||||
*/
|
||||
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -483,9 +553,111 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
||||
/*
|
||||
1. Everything this commander is still running — the SESSION first, then the loop.
|
||||
|
||||
Killing the loop alone is not enough, and its own test proved it: the loop is a grandchild of the
|
||||
commander's `claude` process, so ending the loop leaves the session alive — and a live session REWRITES
|
||||
its transcript after the file is deleted. Twice tonight a deleted commander's transcript reappeared, and
|
||||
twice it read as the delete having failed.
|
||||
|
||||
`killClaudeSession` takes officer's session key while this route holds a transcript id, so the map is
|
||||
read backwards — `findSessionKeyByClaudeSession`, which exists for the same reason a refreshed browser
|
||||
holds only the uuid. Ending the session takes its children with it, and the pid-based stop below then
|
||||
catches anything that outlived its parent.
|
||||
*/
|
||||
// Same mapping problem as the briefing: the sidecar's map is not visible from here, so the platform's own
|
||||
// is asked first and the sidecar's is kept as a fallback for a session that predates this process.
|
||||
/*
|
||||
Third fallback, and the only one that survives a restart.
|
||||
|
||||
Both maps above are in-memory and answer only for a session younger than this process. After a
|
||||
restart they both return undefined, no kill is sent, and the transcript is deleted while the harness
|
||||
is still writing — so it writes it back and the deleted commander returns as an untitled stub. Seen
|
||||
twice tonight, after the fix that was meant to prevent exactly this.
|
||||
*/
|
||||
const sessionKey =
|
||||
sessionKeyForTranscript(sessionId) ??
|
||||
findSessionKeyByClaudeSession(sessionId, user.id) ??
|
||||
(await sessionKeyForTranscriptId(sessionId).catch(() => undefined));
|
||||
/*
|
||||
Across the socket, and AWAITED — both halves matter.
|
||||
|
||||
`killClaudeSession` from `claude-manager` was called directly here, and it could only ever return false:
|
||||
the session map lives in the `officer-claude-code` process and this route runs in `officer`, so it was
|
||||
reading an empty map in the wrong process. The kill silently never happened.
|
||||
|
||||
Awaited because the next thing this route does is delete the transcript, and the `claude` CLI owns that
|
||||
file. Deleting it while the CLI is still unwinding removes a file that is written straight back — which
|
||||
is exactly how a deleted commander came back as an untitled ordinary chat.
|
||||
*/
|
||||
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
|
||||
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||
// not stop the rest from going, and a child is often gone before its parent.
|
||||
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
||||
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
||||
(c) => c.spawnedSessionId,
|
||||
);
|
||||
let childrenDeleted = 0;
|
||||
// Every transcript id actually removed — the commander's and its children's, chains included. Collected
|
||||
// rather than assumed, because a `/clear` chain is several ids behind one conversation.
|
||||
const forgotten: string[] = [];
|
||||
for (const child of children) {
|
||||
try {
|
||||
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
|
||||
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
|
||||
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
|
||||
// transcript wherever it actually is, which is what makes this work for a child that ran in a
|
||||
// different directory from its parent.
|
||||
//
|
||||
// `.length`, not truthiness: this returns the ids it removed, and an empty array is truthy.
|
||||
const removedChild = deleteClaudeSession(who, who.home, child);
|
||||
if (removedChild.length) {
|
||||
childrenDeleted += 1;
|
||||
forgotten.push(...removedChild);
|
||||
}
|
||||
} catch {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
|
||||
// 3. The commander itself.
|
||||
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
|
||||
await deleteCommandHqSession(user.id, sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
/*
|
||||
Forget what the platform stored ABOUT every transcript that just went — the commander's and its
|
||||
children's, in one call.
|
||||
|
||||
The `command_hq_*` tables cascade from the row, because they reference it. These four do not: they are
|
||||
keyed on a transcript id, which is a FILE, so there is no row for them to reference and nothing to
|
||||
cascade from. Left behind they are mostly inert — a model, a bell preference, a durable copy of turn
|
||||
output — except `nudges`, which is a live schedule that would keep firing at a conversation that no
|
||||
longer exists.
|
||||
*/
|
||||
/*
|
||||
`sessionKeys` carries the commander's own key and nothing else, on purpose. Children are spawned by
|
||||
`claude -p` outside officer's websocket, so they never had a key and never wrote a durable event — an
|
||||
empty entry for them would be noise, not caution.
|
||||
|
||||
`sessionKey` is resolved above for the kill and reused here rather than looked up twice; when it is null
|
||||
the session was already gone, and so were its events.
|
||||
*/
|
||||
await forgetChatSessions({ transcriptIds: forgotten, sessionKeys: sessionKey ? [sessionKey] : [] });
|
||||
|
||||
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
|
||||
// make it auditable after the fact.
|
||||
return ctx.json({
|
||||
ok: true,
|
||||
sessionKilled,
|
||||
processesStopped: stopped,
|
||||
childrenFound: children.length,
|
||||
childrenDeleted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -498,8 +670,16 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
commandHqRouter.post('/purge', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const live = listClaudeSessions(who, getCommandersDir(who.email));
|
||||
const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id)));
|
||||
/*
|
||||
"Still exists" is now per row, wherever that row's transcript lives.
|
||||
|
||||
Scanning one directory would have deleted every commander that chose a different one — a housekeeping
|
||||
route quietly unfiling live commanders is the worst possible version of this bug, because the rows are
|
||||
what identity depends on now.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id);
|
||||
const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id)));
|
||||
const removed = await purgeCommandHqSessions(user.id, stillThere);
|
||||
return ctx.json({ removed });
|
||||
});
|
||||
|
||||
@@ -518,6 +698,59 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/children
|
||||
*
|
||||
* The agents this commander spawned, ingested from the loop's inbox on the way through.
|
||||
*
|
||||
* Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop
|
||||
* without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on
|
||||
* the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the
|
||||
* UI likes.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
||||
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
||||
// this exists.
|
||||
const known = children.filter((c) => c.totalUsd !== null);
|
||||
return ctx.json({
|
||||
children,
|
||||
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
|
||||
unknownCost: children.length - known.length,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
*
|
||||
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||
*
|
||||
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
||||
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderStateDir } from '../../data-path';
|
||||
import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
/*
|
||||
What a commander spawned, and why the platform cannot work it out for itself.
|
||||
|
||||
A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself
|
||||
started. The platform never sees that spawn: it happens inside a background job, two processes below a
|
||||
conversation, and the agent talks to Anthropic rather than to us.
|
||||
|
||||
Three ways to recover the link were tried and none works:
|
||||
|
||||
- the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing
|
||||
identifying in its environment. Verified 2026-08-22.
|
||||
- the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look
|
||||
like markers the platform injects and are the harness's own, appearing in every session equally. A
|
||||
discriminator built on them rejected a real child and reported zero when one was running.
|
||||
- CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches
|
||||
as well as the agent following it.
|
||||
|
||||
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||
|
||||
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||
|
||||
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||
every link is required.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
const PID_FILE = 'loop-pid';
|
||||
|
||||
/** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every agent this commander recorded, oldest first, de-duplicated.
|
||||
*
|
||||
* Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a
|
||||
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||
*/
|
||||
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||
const path = spawnedRecordPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
|
||||
const id = raw.trim();
|
||||
if (SESSION_ID.test(id)) seen.add(id.toLowerCase());
|
||||
}
|
||||
return [...seen];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one, idempotently. For the platform's own use — the loop appends directly, by design.
|
||||
*
|
||||
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||
* that arrives it must write the same file rather than a second record.
|
||||
*/
|
||||
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||
} catch {
|
||||
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||
// without a commander to read it for.
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
── Stopping what a commander is running ──
|
||||
|
||||
The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and
|
||||
a `claude` process cannot be attributed to a particular commander from outside — every commander's session
|
||||
runs with the same cwd, so the process table cannot tell them apart.
|
||||
|
||||
So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the
|
||||
only party that knows. From that one number the whole tree follows — the loop's children include any agent
|
||||
it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct
|
||||
child, so a running grandchild is left orphaned and keeps going").
|
||||
|
||||
A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed
|
||||
before this existed, and pretending otherwise would report a clean delete over a loop still polling.
|
||||
*/
|
||||
|
||||
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||
const path = loopPidPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
try {
|
||||
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||
const pid = liveLoopPid(email, commanderSessionId);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
let signalled = 0;
|
||||
for (const target of tree) {
|
||||
try {
|
||||
process.kill(target, 'SIGTERM');
|
||||
signalled += 1;
|
||||
} catch {
|
||||
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
return signalled;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { sessionStatus, startSession, stopSession } from './session';
|
||||
|
||||
/**
|
||||
* Session control. The pixels do not come through here — they come through the websocket relay; this
|
||||
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
|
||||
* canvas.
|
||||
*/
|
||||
export const minesweeperRouter = createRouter();
|
||||
|
||||
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
|
||||
|
||||
minesweeperRouter.post('/start', async (c) => {
|
||||
const status = await sessionStatus();
|
||||
if (!status.installed) {
|
||||
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
|
||||
}
|
||||
if (status.running) return c.json(status);
|
||||
|
||||
const result = await startSession();
|
||||
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
|
||||
minesweeperRouter.post('/stop', async (c) => {
|
||||
await stopSession();
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ROOT } from '../../data-path';
|
||||
|
||||
/**
|
||||
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
|
||||
* display on loopback.
|
||||
*
|
||||
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
|
||||
* used by hand. One implementation, so a session started from the browser and one started from a
|
||||
* terminal are the same session with the same lifecycle.
|
||||
*/
|
||||
|
||||
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
|
||||
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
|
||||
|
||||
export const VNC_HOST = '127.0.0.1';
|
||||
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
|
||||
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
|
||||
|
||||
export type WindowGeometry = { width: number; height: number; x: number; y: number };
|
||||
export type SessionStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
/** Absent until the game has drawn its window. */
|
||||
window: WindowGeometry | null;
|
||||
};
|
||||
|
||||
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: MINESWEEPER_DIR,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, out: `${out}${err}` };
|
||||
};
|
||||
|
||||
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
|
||||
|
||||
/**
|
||||
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
|
||||
*
|
||||
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
|
||||
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
|
||||
* until the client speaks or the peek times out. A health check that connects and says nothing is
|
||||
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
|
||||
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
|
||||
*
|
||||
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
|
||||
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
|
||||
*/
|
||||
async function isServing(): Promise<boolean> {
|
||||
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
|
||||
const LISTEN = '0A';
|
||||
|
||||
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
||||
const text = await Bun.file(table)
|
||||
.text()
|
||||
.catch(() => '');
|
||||
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
const local = columns[1];
|
||||
const state = columns[3];
|
||||
if (!local || state !== LISTEN) continue;
|
||||
if (local.split(':')[1] === wanted) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The game window's size, so the browser can size its canvas to the board rather than to the whole
|
||||
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
|
||||
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
|
||||
*
|
||||
* The board changes size when the difficulty changes, which is why this is polled rather than read
|
||||
* once at connect.
|
||||
*/
|
||||
export async function windowGeometry(): Promise<WindowGeometry | null> {
|
||||
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
|
||||
if (!search.ok) return null;
|
||||
|
||||
let best: WindowGeometry | null = null;
|
||||
for (const id of search.out.trim().split('\n').filter(Boolean)) {
|
||||
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
|
||||
if (!geom.ok) continue;
|
||||
|
||||
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
|
||||
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
|
||||
if (Object.values(candidate).some(Number.isNaN)) continue;
|
||||
|
||||
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
|
||||
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
|
||||
}
|
||||
return best && best.width > 1 ? best : null;
|
||||
}
|
||||
|
||||
export async function sessionStatus(): Promise<SessionStatus> {
|
||||
const installed = isInstalled();
|
||||
if (!installed) return { installed, running: false, window: null };
|
||||
|
||||
const running = await isServing();
|
||||
return { installed, running, window: running ? await windowGeometry() : null };
|
||||
}
|
||||
|
||||
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
|
||||
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import { VNC_HOST, VNC_PORT } from './session';
|
||||
|
||||
/**
|
||||
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
|
||||
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
|
||||
* reading them.
|
||||
*
|
||||
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
|
||||
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
|
||||
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
|
||||
* and chunks are forwarded exactly as they arrive.
|
||||
*/
|
||||
|
||||
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
|
||||
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
|
||||
export const minesweeperWebsocket = {
|
||||
open(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
|
||||
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
|
||||
// version string would otherwise lose it.
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
void Bun.connect({
|
||||
hostname: VNC_HOST,
|
||||
port: VNC_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
relay.queue.length = 0;
|
||||
},
|
||||
data(_socket, chunk) {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tears down the socket */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1000, 'vnc closed'),
|
||||
error: () => closeClient(ws, 1011, 'vnc error'),
|
||||
},
|
||||
}).catch(() => {
|
||||
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
|
||||
// panel can say so instead of showing an empty canvas.
|
||||
closeClient(ws, 1011, 'minesweeper session is not running');
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
|
||||
// RFB is binary in both directions; a text frame is a client that is not speaking it.
|
||||
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Context } from 'hono';
|
||||
import { listThemeFavorites, setThemeFavorite } from 'officerdb';
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
/*
|
||||
Starred themes, per account.
|
||||
|
||||
── Why this is not on `/themes` ──
|
||||
|
||||
Because that prefix is deliberately UNAUTHENTICATED: the sign-in screen renders in the chosen theme, so
|
||||
the list and the stylesheets have to be readable before anyone has an account. A per-account preference on
|
||||
an anonymous prefix has no caller to attribute it to — the first version of this lived there and answered
|
||||
500 to an anonymous GET, which was the honest consequence arriving early.
|
||||
|
||||
── Why the `account` permission ──
|
||||
|
||||
It is `core`, so every account holds it and it cannot be denied, and it already owns `/user`, `/dock` and
|
||||
`/api-keys` — preferences and credentials belonging to the caller themselves. A theme favourite is the
|
||||
same shape as a dock preference: it grants nothing, it is about this account's own eyes, and denying it
|
||||
would only mean an account that can choose a theme cannot remember which ones it liked.
|
||||
|
||||
Every route here reads `user.id` from the token. None of them names an account, so there is nothing to get
|
||||
wrong about whose stars these are.
|
||||
*/
|
||||
export const themeFavoritesRouter = createRouter();
|
||||
|
||||
// GET /theme-favorites — this account's starred theme ids.
|
||||
themeFavoritesRouter.get('/', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ favorites: await listThemeFavorites(user.id) });
|
||||
});
|
||||
|
||||
// PUT /theme-favorites/:id — star or unstar one.
|
||||
//
|
||||
// PUT rather than POST because it SETS a state rather than appending: sending `{favorite:true}` twice ends
|
||||
// where sending it once does, which is what a double-tap and two racing tabs both need. The store is
|
||||
// idempotent in both directions for the same reason.
|
||||
themeFavoritesRouter.put('/:id', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
const themeId = ctx.req.param('id');
|
||||
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const favorite = body.favorite !== false;
|
||||
return ctx.json({ themeId, favorite: await setThemeFavorite(user.id, themeId, favorite) });
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import { discoverThemes, readThemeCss } from '../../themes/discover';
|
||||
/**
|
||||
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
|
||||
*
|
||||
* **This prefix is UNAUTHENTICATED** (`UNPROTECTED_API_PREFIXES` in hono.ts) and must stay that way: the
|
||||
* sign-in screen paints in the chosen theme, before any account exists to ask. So nothing per-account can
|
||||
* live here — theme FAVOURITES are on `/theme-favorites`, behind the `account` permission, for exactly that
|
||||
* reason. Adding them here returned 500 on an anonymous request, which was the polite version of the
|
||||
* problem.
|
||||
*
|
||||
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request
|
||||
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no
|
||||
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
|
||||
|
||||
@@ -219,6 +219,21 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
*/
|
||||
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
||||
|
||||
/**
|
||||
* Where a commander records what it spawned — one directory per commander, named by its session id.
|
||||
*
|
||||
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
|
||||
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
|
||||
* commander started, because nothing else CAN know them — a spawned agent's id is not recoverable from its
|
||||
* process (no open descriptor, nothing in its environment) and its transcript is indistinguishable from any
|
||||
* other session's.
|
||||
*
|
||||
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
|
||||
* completely, its children cannot be followed, and its cost cannot be totalled.
|
||||
*/
|
||||
export const getCommanderStateDir = (email: string, sessionId: string) =>
|
||||
join(DATA_PATH, email, 'commander-state', sessionId);
|
||||
|
||||
/**
|
||||
* Which mail account's store to use, found on DISK rather than by asking the database.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ import { authRouter } from './api/auth';
|
||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||
import { themesRouter } from './api/themes/themes';
|
||||
import { themeFavoritesRouter } from './api/themes/favorites';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
@@ -25,6 +26,7 @@ import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
@@ -122,6 +124,8 @@ const isDavPath = (path: string) =>
|
||||
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
|
||||
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
||||
['/server-settings', serverSettingsRouter],
|
||||
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
@@ -136,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/dashboards', dashboardsRouter],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import type { EffectivePermissions } from './authorize';
|
||||
import { decideApiRequest, decideWsProvider, resolveEffectivePermissions } from './authorize';
|
||||
import type { PermissionLevel } from './registry';
|
||||
import { CORE_PERMISSIONS } from './registry';
|
||||
|
||||
/*
|
||||
The resolver, which its own header calls the file standing between a Member and a shell — and which had no
|
||||
automated tests at all until 2026-08-22.
|
||||
|
||||
It had none because every path needed two database calls. The decisions are now split from the fetching
|
||||
(`resolveEffectivePermissions`, `decideApiRequest`, `decideWsProvider` are pure), so this file needs no
|
||||
mock, no database and no `officerdb` stub — which matters beyond convenience: the one file in this
|
||||
repository that mocks `officerdb` leaks its stub into whatever runs after it.
|
||||
|
||||
REAL registry keys throughout, deliberately. A suite built on invented permissions would keep passing
|
||||
after a rename, which is the failure mode a test of an authorization table can least afford. If a key here
|
||||
disappears, this file should go red.
|
||||
*/
|
||||
|
||||
const member = (osUser: string | null = 'm1'): { role: UserRole; osUser: string | null } => ({
|
||||
role: 'Member',
|
||||
osUser,
|
||||
});
|
||||
const grants = (...pairs: Array<[string, PermissionLevel]>) => new Map<string, PermissionLevel>(pairs);
|
||||
const resolve = (user: { role: UserRole; osUser: string | null }, g = grants()) => resolveEffectivePermissions(user, g);
|
||||
|
||||
/** No account at all — an unauthenticated or deleted caller, as the async wrapper reports it. */
|
||||
const NOBODY: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||
|
||||
describe('resolving what an account holds', () => {
|
||||
test('the owner bypasses, and carries no grants to consult', () => {
|
||||
const owner = resolve({ role: 'Super Admin', osUser: null });
|
||||
expect(owner.isOwner).toBe(true);
|
||||
// Empty on purpose: an owner's grants are never read, and filling them would invite somebody to.
|
||||
expect(owner.grants.size).toBe(0);
|
||||
});
|
||||
|
||||
test('a member gets every core permission at write, granted nothing', () => {
|
||||
const held = resolve(member()).grants;
|
||||
for (const permission of CORE_PERMISSIONS) expect(held.get(permission.key)).toBe('write');
|
||||
});
|
||||
|
||||
test('core is write even though nothing granted it', () => {
|
||||
// A read-only "change your own password" is not a coherent thing to offer, which is why core is not
|
||||
// levelled. Pinned because a future levels-everywhere refactor would silently break it.
|
||||
expect(resolve(member()).grants.get('account')).toBe('write');
|
||||
});
|
||||
|
||||
test('an app grant is honoured at the level given', () => {
|
||||
expect(resolve(member(), grants(['calendar', 'read'])).grants.get('calendar')).toBe('read');
|
||||
expect(resolve(member(), grants(['calendar', 'write'])).grants.get('calendar')).toBe('write');
|
||||
});
|
||||
|
||||
// The rule that only exists because getOwnerHomeDir ignores its argument: a confined grant with no Linux
|
||||
// user to confine to would resolve to the OWNER's home.
|
||||
test('a confined grant needs a Linux account', () => {
|
||||
expect(resolve(member('m1'), grants(['files', 'write'])).grants.get('files')).toBe('write');
|
||||
expect(resolve(member(null), grants(['files', 'write'])).grants.has('files')).toBe(false);
|
||||
});
|
||||
|
||||
test('every confined permission is dropped together, not one by one', () => {
|
||||
const held = resolve(member(null), grants(['files', 'write'], ['terminal', 'write'], ['chat', 'write']));
|
||||
for (const key of ['files', 'terminal', 'chat']) expect(held.grants.has(key)).toBe(false);
|
||||
});
|
||||
|
||||
// The database has no CHECK stopping these rows; the API refuses to write one. This is the layer that
|
||||
// has to hold if one ever exists.
|
||||
test('an execution grant in the table is ignored', () => {
|
||||
expect(resolve(member(), grants(['tasks', 'write'])).grants.has('tasks')).toBe(false);
|
||||
});
|
||||
|
||||
test('an admin grant in the table is ignored', () => {
|
||||
expect(resolve(member(), grants(['user-admin', 'write'])).grants.has('user-admin')).toBe(false);
|
||||
expect(resolve(member(), grants(['server-admin', 'write'])).grants.has('server-admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('a grant naming a permission that no longer exists is ignored', () => {
|
||||
const held = resolve(member(), grants(['cliamp', 'write'], ['a-plugin-uninstalled-last-year', 'read']));
|
||||
expect(held.grants.has('cliamp')).toBe(false);
|
||||
expect(held.grants.has('a-plugin-uninstalled-last-year')).toBe(false);
|
||||
// …and does not disturb what is legitimately held.
|
||||
expect(held.grants.get('account')).toBe('write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deciding an HTTP request', () => {
|
||||
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||
|
||||
test('the owner is allowed anything, including paths nothing serves', () => {
|
||||
expect(decideApiRequest(owner, 'DELETE', '/api/users/9').allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this').allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('a path no permission covers is refused, not allowed by default', () => {
|
||||
const d = decideApiRequest(NOBODY, 'GET', '/api/nothing-serves-this');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('no permission covers this path');
|
||||
});
|
||||
|
||||
test('an execution permission is refused however it is granted', () => {
|
||||
// Refused by KIND, before any grant lookup — so a row granting it changes nothing.
|
||||
const forged = resolve(member(), grants(['tasks', 'write']));
|
||||
const d = decideApiRequest(forged, 'GET', '/api/tasks');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('runs as the server owner');
|
||||
});
|
||||
|
||||
test('an admin permission is refused', () => {
|
||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/users');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('restricted to the server owner');
|
||||
});
|
||||
|
||||
// The one self-service route in the registry: updating your own profile lives on the router that also
|
||||
// holds account administration.
|
||||
test('a self-service route is allowed with no grant at all', () => {
|
||||
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users').allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('self-service is exact — neither the method nor the path may drift', () => {
|
||||
expect(decideApiRequest(resolve(member()), 'POST', '/api/users').allowed).toBe(false);
|
||||
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users/7').allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('no grant means no access, with the permission named', () => {
|
||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('your role does not have access to');
|
||||
});
|
||||
|
||||
test('read grants reads and refuses writes', () => {
|
||||
const held = resolve(member('m1'), grants(['files', 'read']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
|
||||
const write = decideApiRequest(held, 'POST', '/api/file-browser');
|
||||
expect(write.allowed).toBe(false);
|
||||
expect(write.reason).toContain('read-only');
|
||||
});
|
||||
|
||||
test('write grants both', () => {
|
||||
const held = resolve(member('m1'), grants(['files', 'write']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
|
||||
expect(decideApiRequest(held, 'POST', '/api/file-browser').allowed).toBe(true);
|
||||
});
|
||||
|
||||
// A read that has to be a POST — the body is too big for a query string — must not need write.
|
||||
//
|
||||
// The path is `/api/file-browser/download`, not `/api/download`: readOnlyWrites entries are RELATIVE to
|
||||
// the permission's own api prefix. Worth a comment because the absolute form looks right, is what I wrote
|
||||
// first, and fails as "no permission covers this path" rather than as a level problem — a misleading
|
||||
// error for a misleading path.
|
||||
test('a readOnlyWrites path is allowed at read level', () => {
|
||||
const held = resolve(member('m1'), grants(['files', 'read']));
|
||||
expect(decideApiRequest(held, 'POST', '/api/file-browser/download').allowed).toBe(true);
|
||||
// And the same POST elsewhere under the prefix still needs write.
|
||||
expect(decideApiRequest(held, 'POST', '/api/file-browser/move').allowed).toBe(false);
|
||||
});
|
||||
|
||||
// The two rules composing: granted, but with no kernel boundary to be granted inside.
|
||||
test('a granted confined permission is still refused without a Linux account', () => {
|
||||
const held = resolve(member(null), grants(['files', 'write']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('an unauthenticated caller is refused everything', () => {
|
||||
for (const path of ['/api/file-browser', '/api/chat', '/api/users', '/api/tasks']) {
|
||||
expect(decideApiRequest(NOBODY, 'GET', path).allowed).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deciding a websocket', () => {
|
||||
test('the owner opens anything', () => {
|
||||
expect(decideWsProvider({ isOwner: true, grants: new Map() }, 'terminal')).toBe(true);
|
||||
});
|
||||
|
||||
test('a provider no permission claims is refused', () => {
|
||||
expect(decideWsProvider(resolve(member()), 'not-a-provider')).toBe(false);
|
||||
});
|
||||
|
||||
test('a confined socket needs the grant AND the Linux account', () => {
|
||||
expect(decideWsProvider(resolve(member('m1'), grants(['terminal', 'read'])), 'terminal')).toBe(true);
|
||||
expect(decideWsProvider(resolve(member(null), grants(['terminal', 'read'])), 'terminal')).toBe(false);
|
||||
});
|
||||
|
||||
// No method to reason about, so any level opens it. Pinned because "read-only socket" is a tempting idea
|
||||
// that would silently close terminals granted at read.
|
||||
test('read is enough — a socket has no method', () => {
|
||||
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat')).toBe(true);
|
||||
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat-notify')).toBe(true);
|
||||
});
|
||||
|
||||
test("an execution permission's socket is refused however it is granted", () => {
|
||||
const forged = resolve(member('m1'), grants(['tasks', 'write']));
|
||||
expect(decideWsProvider(forged, 'task-runner')).toBe(false);
|
||||
expect(decideWsProvider(forged, 'pipeline')).toBe(false);
|
||||
});
|
||||
|
||||
test('an unauthenticated caller opens nothing', () => {
|
||||
for (const p of ['terminal', 'chat', 'task-runner', 'sidecar']) expect(decideWsProvider(NOBODY, p)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
NOT covered here, and stated rather than left as a gap somebody has to notice:
|
||||
|
||||
- `isOwnerOnlyRoute`. No permission in the registry declares `ownerOnly` yet — the field was added on
|
||||
2026-08-21 for plugin settings routes and has no user. It is checked before self-service and before
|
||||
kind, which is the part worth a test, and it cannot get one until a real permission uses it. Covering it
|
||||
with an invented permission would test this file's fixtures rather than the registry.
|
||||
- The grant cache and `invalidateRoleGrants`. They live on the async side, around a database call, which
|
||||
is exactly the half this split moved away from. Their contract — the one writer in
|
||||
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
|
||||
and a test here could only re-state it.
|
||||
*/
|
||||
@@ -65,6 +65,57 @@ async function grantsForRole(role: UserRole): Promise<Map<string, PermissionLeve
|
||||
* write such a row, but this is the layer that has to hold if one ever exists — a constraint the database
|
||||
* does not enforce is a constraint the reader must.
|
||||
*/
|
||||
/**
|
||||
* The resolution itself, with no database in it.
|
||||
*
|
||||
* Split out on 2026-08-22 so it can be tested. `authorize.ts` is described in its own header as the file
|
||||
* standing between a Member and a shell, and it had no automated tests at all — because every path through
|
||||
* it needed two database calls, and the only file in this repository that mocks `officerdb` has a stub that
|
||||
* leaks into whatever runs after it. Pulling the decision away from the fetching removes the need for a
|
||||
* mock rather than working around one: everything below this line is a pure function of two arguments.
|
||||
*
|
||||
* The async wrapper keeps the caching, the error handling and the single-question shape callers rely on.
|
||||
*/
|
||||
export function resolveEffectivePermissions(
|
||||
user: { role: UserRole; osUser: string | null },
|
||||
roleGrants: Map<string, PermissionLevel>,
|
||||
): EffectivePermissions {
|
||||
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||
|
||||
const grants = new Map<string, PermissionLevel>();
|
||||
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
|
||||
|
||||
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
|
||||
// without it — see below.
|
||||
const hasOsAccount = !!user.osUser;
|
||||
|
||||
for (const [key, level] of roleGrants) {
|
||||
const permission = PERMISSION_BY_KEY.get(key);
|
||||
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
||||
// alternative is honouring a name nothing defines.
|
||||
if (!permission) continue;
|
||||
|
||||
// A confined permission touches the filesystem or runs a process, and is safe only because the
|
||||
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
|
||||
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
|
||||
// `getOwnerHomeDir` ignores the email it is passed, always.
|
||||
//
|
||||
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
|
||||
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
|
||||
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
|
||||
if (permission.kind === 'confined') {
|
||||
if (!hasOsAccount) continue;
|
||||
grants.set(key, level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permission.kind !== 'app') continue;
|
||||
grants.set(key, level);
|
||||
}
|
||||
|
||||
return { isOwner: false, grants };
|
||||
}
|
||||
|
||||
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
|
||||
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||
if (!userId) return empty;
|
||||
@@ -73,39 +124,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
|
||||
const user = await getUserById(userId);
|
||||
if (!user) return empty;
|
||||
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||
|
||||
const grants = new Map<string, PermissionLevel>();
|
||||
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
|
||||
|
||||
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
|
||||
// without it — see below.
|
||||
const hasOsAccount = !!user.osUser;
|
||||
|
||||
for (const [key, level] of await grantsForRole(user.role)) {
|
||||
const permission = PERMISSION_BY_KEY.get(key);
|
||||
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
||||
// alternative is honouring a name nothing defines.
|
||||
if (!permission) continue;
|
||||
|
||||
// A confined permission touches the filesystem or runs a process, and is safe only because the
|
||||
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
|
||||
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
|
||||
// `getOwnerHomeDir` ignores the email it is passed, always.
|
||||
//
|
||||
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
|
||||
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
|
||||
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
|
||||
if (permission.kind === 'confined') {
|
||||
if (!hasOsAccount) continue;
|
||||
grants.set(key, level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permission.kind !== 'app') continue;
|
||||
grants.set(key, level);
|
||||
}
|
||||
|
||||
return { isOwner: false, grants };
|
||||
return resolveEffectivePermissions(user, await grantsForRole(user.role));
|
||||
} catch {
|
||||
// A transient database error must never grant anything. Deny, and let the next request retry.
|
||||
return empty;
|
||||
@@ -123,7 +142,16 @@ export async function isApiRequestAllowed(
|
||||
method: string,
|
||||
path: string,
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
||||
return decideApiRequest(await getEffectivePermissions(userId), method, path);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
|
||||
export function decideApiRequest(
|
||||
effective: EffectivePermissions,
|
||||
method: string,
|
||||
path: string,
|
||||
): { allowed: boolean; reason?: string } {
|
||||
const { isOwner, grants } = effective;
|
||||
if (isOwner) return { allowed: true };
|
||||
|
||||
const permission = permissionForApiPath(path);
|
||||
@@ -170,7 +198,12 @@ export async function isApiRequestAllowed(
|
||||
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
||||
*/
|
||||
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
||||
return decideWsProvider(await getEffectivePermissions(userId), provider);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
||||
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
|
||||
const { isOwner, grants } = effective;
|
||||
if (isOwner) return true;
|
||||
|
||||
const permission = permissionForWsProvider(provider);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
|
||||
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
|
||||
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
|
||||
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
|
||||
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar'];
|
||||
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
|
||||
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
|
||||
const surface = () => ({
|
||||
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
|
||||
|
||||
@@ -147,7 +147,10 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// to present the authority the account already has, so denying it would only force the holder to keep
|
||||
// using a password in places a password should not go. What a key can then DO is decided by the same
|
||||
// permission checks as any other request from that user; nothing here widens them.
|
||||
api: ['/user', '/dock', '/api-keys'],
|
||||
// `/theme-favorites` sits here rather than on `/themes`, which is unauthenticated so the sign-in screen
|
||||
// can paint. A starred theme is the same shape as a dock preference: the caller's own taste, granting
|
||||
// nothing, and not a sensible thing to deny an account that can already choose a theme.
|
||||
api: ['/user', '/dock', '/api-keys', '/theme-favorites'],
|
||||
routes: ['/settings/profile'],
|
||||
},
|
||||
{
|
||||
@@ -325,6 +328,25 @@ const CORE_REGISTRY: Permission[] = [
|
||||
ws: ['task-runner', 'pipeline'],
|
||||
routes: ['/jobs'],
|
||||
},
|
||||
// Minesweeper X, on a virtual display, streamed to the browser over RFB.
|
||||
//
|
||||
// `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs
|
||||
// as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the
|
||||
// owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the
|
||||
// owner". `plugins/desktop` reached the same conclusion from the same premise.
|
||||
//
|
||||
// There is a second reason to keep it here for now: one display and one game means two viewers share a
|
||||
// board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which
|
||||
// is cheap for this but is a design decision rather than an implementation detail.
|
||||
{
|
||||
key: 'minesweeper',
|
||||
label: 'Minesweeper',
|
||||
description: 'A Windows Minesweeper session on a virtual display, as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/minesweeper'],
|
||||
ws: ['minesweeper'],
|
||||
routes: ['/minesweeper'],
|
||||
},
|
||||
{
|
||||
key: 'items',
|
||||
label: 'Permission authoring',
|
||||
|
||||
@@ -224,11 +224,28 @@ function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIME
|
||||
});
|
||||
}
|
||||
|
||||
function sendFire(kind: string, cmd: SidecarCommand): void {
|
||||
/**
|
||||
* Fire a command at whichever sidecar handles this kind. Returns whether one was there to take it.
|
||||
*
|
||||
* It used to return void and drop the command when no sidecar was registered — no log, no error, nothing.
|
||||
* That window is real and predictable: every `pm2 restart officer` has one while the sidecars reconnect, and
|
||||
* a person clicking a button has no idea they are in it.
|
||||
*
|
||||
* The cost landed on `claude:question-answer`, whose own handler documents this exact failure and cannot
|
||||
* report it, because the message never arrives to be reported. From the person's side the question card just
|
||||
* kept spinning — indistinguishable from a slow agent, so the only move left was to send another message.
|
||||
*
|
||||
* Callers that can tell somebody are now able to. The boolean is the whole mechanism; the warning is for
|
||||
* whoever reads the log afterwards wondering why one click did nothing.
|
||||
*/
|
||||
function sendFire(kind: string, cmd: SidecarCommand): boolean {
|
||||
const sc = findSidecarHandling(kind);
|
||||
if (sc) {
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
if (!sc) {
|
||||
console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`);
|
||||
return false;
|
||||
}
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendCommandToSidecar(
|
||||
@@ -330,6 +347,26 @@ export function killClaude(sessionKey: string, userId: number): void {
|
||||
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a session and wait for the agent to confirm its child process is gone.
|
||||
*
|
||||
* For callers that then delete the session's FILES. The transcript belongs to the `claude` CLI, not to
|
||||
* officer, so removing it while the CLI is still unwinding deletes a file that is written straight back —
|
||||
* which resurrects a conversation the user deleted, as an untitled ordinary chat that nothing filters out.
|
||||
*
|
||||
* Never fatal: a sidecar that is down or slow must not block a delete. The caller proceeds and accepts the
|
||||
* race it was already running, which is no worse than before this existed.
|
||||
*/
|
||||
export async function killClaudeAndWait(sessionKey: string, userId: number): Promise<boolean> {
|
||||
try {
|
||||
const sc = await waitForHandler('claude');
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey, userId });
|
||||
return res.type === 'claude:killed';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
||||
export function interruptClaude(sessionKey: string, userId: number): void {
|
||||
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
|
||||
@@ -348,8 +385,8 @@ export function decideClaudePlan(params: {
|
||||
requestId: string;
|
||||
approve: boolean;
|
||||
feedback?: string;
|
||||
}): void {
|
||||
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
||||
}): boolean {
|
||||
return sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
||||
}
|
||||
|
||||
// The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the
|
||||
@@ -360,8 +397,8 @@ export function answerClaudeQuestion(params: {
|
||||
userId: number;
|
||||
toolCallId: string;
|
||||
answer: string;
|
||||
}): void {
|
||||
sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
||||
}): boolean {
|
||||
return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -247,6 +247,18 @@ type PersistentSession = {
|
||||
interrupted: boolean;
|
||||
/** What we passed as `--resume`, so a failure can name it and decide whether it was to blame. */
|
||||
resumeId?: string;
|
||||
/**
|
||||
* Resolves when this session's child process is actually gone.
|
||||
*
|
||||
* Killing is not the same as being dead, and the difference is visible on disk: `abort()` returns at once
|
||||
* while the `claude` CLI keeps writing for as long as it takes to unwind. Officer does not own the
|
||||
* transcript — the CLI does — so a delete that races this window removes the file and the CLI writes it
|
||||
* straight back, resurrecting a conversation the user deleted. Anything that deletes a session's FILES
|
||||
* has to await this first.
|
||||
*/
|
||||
exited: Promise<void>;
|
||||
/** Called by the exit hook. Resolving twice is a no-op, which is what makes the double-fire safe. */
|
||||
markExited: () => void;
|
||||
/**
|
||||
* The model this session is currently answering as, `harness/tier`.
|
||||
*
|
||||
@@ -362,6 +374,10 @@ function spawnClaudeAsOwner({ command, args, cwd, env, signal }: SpawnOptions):
|
||||
* transcript by id.
|
||||
*/
|
||||
function dropDeadSession(session: PersistentSession, detail: string): void {
|
||||
// Before the identity guard, and deliberately: this fires on the real process exit, and a waiter must be
|
||||
// released even when a newer session has already taken this key. Otherwise a replaced session's waiter
|
||||
// blocks until its timeout for a process that died on schedule.
|
||||
session.markExited();
|
||||
// Identity, not key: a later turn may have already replaced this entry, and killing its session because
|
||||
// its predecessor's process exited would break the live conversation instead of a dead one.
|
||||
if (sessions.get(session.sessionKey) !== session) return;
|
||||
@@ -562,7 +578,13 @@ function createSession(
|
||||
interrupted: false,
|
||||
stderrTail: '',
|
||||
sawOutput: false,
|
||||
// Assigned immediately below — the resolver has to escape the executor before the object is usable.
|
||||
exited: undefined as unknown as Promise<void>,
|
||||
markExited: () => {},
|
||||
};
|
||||
session.exited = new Promise<void>((resolve) => {
|
||||
session.markExited = resolve;
|
||||
});
|
||||
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
const cleanEnv = envWithoutParentSession();
|
||||
@@ -608,6 +630,20 @@ function createSession(
|
||||
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
|
||||
// written an hour ago should be offered on the next session.
|
||||
const skills = skillsBlock();
|
||||
/*
|
||||
And, for a commander, the two paths only this session can be told.
|
||||
|
||||
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
|
||||
preamble, which is where this used to live and which an agent arming itself never sees. See
|
||||
`api/command-hq/commander-block.ts`.
|
||||
*/
|
||||
/*
|
||||
No commander block here, deliberately.
|
||||
|
||||
It used to be composed in, and could never arrive: this runs once per session, and a commander's first
|
||||
turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the
|
||||
preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts.
|
||||
*/
|
||||
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
|
||||
|
||||
const q = query({
|
||||
@@ -1222,6 +1258,33 @@ export async function interruptClaudeSession(sessionKey: string, userId: number)
|
||||
}
|
||||
|
||||
/** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */
|
||||
/**
|
||||
* Kill a session and wait for its child process to actually be gone.
|
||||
*
|
||||
* The bounded wait is the point. `killClaudeSession` returns as soon as it has aborted, which is before the
|
||||
* `claude` CLI has finished writing — and the CLI, not officer, owns the transcript file. A caller that
|
||||
* deletes files has to await this or it deletes them out from under a process still writing them.
|
||||
*
|
||||
* The timeout resolves rather than throws: a child that will not die within the window is a worse problem
|
||||
* than a stale transcript, and blocking a user's delete on it forever helps nobody. The caller gets `false`
|
||||
* and can say so.
|
||||
*/
|
||||
export async function killClaudeSessionAndWait(sessionKey: string, userId: number, timeoutMs = 5000): Promise<boolean> {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
const { exited } = session;
|
||||
killClaudeSession(sessionKey, userId);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
exited.then(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return !timedOut;
|
||||
}
|
||||
|
||||
export function killClaudeSession(sessionKey: string, userId: number): boolean {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
|
||||
@@ -294,11 +294,20 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey, cmd.userId);
|
||||
case 'claude:kill': {
|
||||
/*
|
||||
Reply only once the child is actually gone.
|
||||
|
||||
The caller deleting a session's transcript has to know the CLI has stopped writing it, and this
|
||||
reply is the only signal it can get — officer's side of the socket has no view of the process.
|
||||
Bounded inside `killClaudeSessionAndWait`, so a child that will not die delays this by seconds
|
||||
rather than hanging the caller's request.
|
||||
*/
|
||||
await claudeManager.killClaudeSessionAndWait(cmd.sessionKey, cmd.userId);
|
||||
sessionLog.drop(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:interrupt':
|
||||
await claudeManager.interruptClaudeSession(cmd.sessionKey, cmd.userId);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
@@ -28,6 +29,7 @@ export const apps = [
|
||||
...widgetMetas,
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...minesweeperMetas,
|
||||
...davMetas,
|
||||
...commandHqMetas,
|
||||
...appStoreMetas,
|
||||
|
||||
@@ -19,6 +19,8 @@ type ResumeState = {
|
||||
messages: ChatMessage[];
|
||||
total: number;
|
||||
offset: number;
|
||||
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
|
||||
isCommander: boolean;
|
||||
};
|
||||
|
||||
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
|
||||
@@ -82,16 +84,23 @@ const ChatPanelInner = ({
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSession(chat.sessionId);
|
||||
// Only a REAL id, never null.
|
||||
//
|
||||
// A new chat is nameless until the harness names it on the first turn, and recording that id is what
|
||||
// makes "New chat, say something, reload" return to that conversation instead of a third one. But
|
||||
// `chat.sessionId` is also null for a moment while a resumed session connects — and writing that null
|
||||
// would turn "this panel has never chosen" into "the user asked for a new chat", so the next reload
|
||||
// would open blank. Asking for a new conversation is something the header says explicitly; this
|
||||
// effect only ever reports what the harness decided.
|
||||
if (chat.sessionId) onSessionId?.(chat.sessionId);
|
||||
}, [chat.sessionId]);
|
||||
/*
|
||||
`claudeSessionId`, NOT `sessionId`. The panel's stored id is looked up in the sessions LIST — see
|
||||
`ChatHeader`'s `sessions.find((s) => s.id === sessionId)` — and that list is keyed on Claude's
|
||||
transcript id. Recording officer's own session key here put two id spaces on either side of one
|
||||
lookup: it never matched, so a new chat's header read "New chat" for the life of the conversation and
|
||||
offered no rename, because rename is a per-row action on a list the session was not found in.
|
||||
Selecting the conversation from the list fixed it, which is why navigating away and back appeared to
|
||||
be the cure.
|
||||
|
||||
Only a REAL id, never null. A new chat has none until the harness names its transcript on the first
|
||||
turn, and recording it is what makes "new chat, say something, reload" return to that conversation
|
||||
rather than a third one. Both ids are also null for a moment while a resumed session connects, and
|
||||
writing that null would turn "this panel has never chosen" into "the user asked for a new chat", so
|
||||
the next reload would open blank.
|
||||
*/
|
||||
if (chat.claudeSessionId) onSessionId?.(chat.claudeSessionId);
|
||||
}, [chat.sessionId, chat.claudeSessionId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -104,6 +113,9 @@ const ChatPanelInner = ({
|
||||
promptPrefix={promptPrefix}
|
||||
panelId={panelId}
|
||||
sessionId={sessionKey ?? undefined}
|
||||
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
|
||||
// set it via `context`; either being true is enough — see `manager.isCommander`.
|
||||
isCommander={resume?.isCommander === true}
|
||||
{...chatContext}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,6 +233,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
messages: detail.messages as unknown as ChatMessage[],
|
||||
total: detail.total,
|
||||
offset: detail.offset,
|
||||
isCommander: detail.isCommander === true,
|
||||
});
|
||||
} catch {
|
||||
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { UseChatType } from '../../../hooks/useChat';
|
||||
import { useEmbeddableChat } from './useEmbeddableChat';
|
||||
import { MessageList } from '../components/MessageList';
|
||||
@@ -5,6 +6,14 @@ import { InputArea } from '../components/InputArea';
|
||||
|
||||
type EmbeddableChatProps = {
|
||||
sessionId?: string;
|
||||
/**
|
||||
* What the transcript shows before there is anything in it. `null` shows nothing.
|
||||
*
|
||||
* The default, "Send a message to start", is true on the chat screen and wrong wherever something else
|
||||
* already explains what to do — a new commander has a setup card and a Start button above it, so the
|
||||
* message contradicts them and takes 200px of height while doing it.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/** Which panel this is — see `queue-store`. Absent on the screen, where there is only one chat. */
|
||||
panelId?: string;
|
||||
initialModel?: string | null;
|
||||
@@ -23,6 +32,22 @@ type EmbeddableChatProps = {
|
||||
chat?: UseChatType;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/**
|
||||
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
|
||||
*
|
||||
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
|
||||
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
|
||||
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
/**
|
||||
* Refuse input entirely, with a reason the composer shows.
|
||||
*
|
||||
* For a surface where something must be chosen before a conversation can start — a new commander needs a
|
||||
* name and a working directory, both unchangeable afterwards. Disabling the composer is the only honest
|
||||
* place to enforce that: anywhere later and the person has already written what they meant to say.
|
||||
*/
|
||||
disabled?: false | { reason: string };
|
||||
onMessageComplete?: () => void;
|
||||
};
|
||||
|
||||
@@ -35,7 +60,7 @@ export const EmbeddableChat = ({ className, onMessageComplete, ...params }: Embe
|
||||
return (
|
||||
<div className={`flex flex-col ${className ?? ''}`} onKeyDown={manager.handleEscape}>
|
||||
<MessageList manager={manager} />
|
||||
<InputArea manager={manager} />
|
||||
<InputArea manager={manager} disabled={params.disabled} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useUserVisibleModels } from 'state/useModels';
|
||||
@@ -49,6 +50,9 @@ type UseEmbeddableChatParams = {
|
||||
chat?: UseChatType;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
|
||||
isCommander?: boolean;
|
||||
emptyState?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -611,6 +615,23 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
// 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,
|
||||
/*
|
||||
One answer to "is this a commander", from either of the two things that can know.
|
||||
|
||||
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
|
||||
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
|
||||
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
|
||||
would lose the brand-new composer, which has no session yet.
|
||||
*/
|
||||
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||
emptyState: params.emptyState,
|
||||
/*
|
||||
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
|
||||
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
|
||||
conversation and is not interchangeable; using it here would key a commander's record on an id nothing
|
||||
else knows, which is the bug that made a working commander render as an empty pane.
|
||||
*/
|
||||
claudeSessionId: chat.claudeSessionId,
|
||||
messages,
|
||||
historicalCount,
|
||||
streamingText,
|
||||
|
||||
@@ -32,6 +32,14 @@ export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
|
||||
export type BackgroundJobSpec = {
|
||||
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
|
||||
mode: 'command' | 'prompt';
|
||||
/**
|
||||
* Where a COMMANDER's loop must record itself. Absent for an ordinary chat's background job.
|
||||
*
|
||||
* Two paths, both handed over rather than guessed, because the loop cannot derive either: it does not know
|
||||
* which commander it belongs to, and nothing outside it knows its pid or what it spawns. Without this
|
||||
* record a commander cannot be stopped or fully deleted — see `api/command-hq/spawned.ts`.
|
||||
*/
|
||||
record?: { loopPidPath: string; spawnedPath: string };
|
||||
/** The literal command, or the intent. */
|
||||
body: string;
|
||||
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
|
||||
@@ -141,9 +149,40 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
A commander's loop must make itself findable. Nothing else can: the platform has no session-to-process
|
||||
map, a spawned agent's id is not recoverable from its process or its transcript, and the loop is the only
|
||||
party that knows either. Without these two lines a commander cannot be stopped or deleted completely —
|
||||
its loop outlives it, still polling and still able to spawn.
|
||||
|
||||
Numbered after whatever came before, like the rules above, and only present when a record path was
|
||||
supplied — an ordinary chat's background job has no commander to belong to.
|
||||
*/
|
||||
// The number the NEXT rule would have taken, so nothing lands on a fraction and the output rule below
|
||||
// simply moves down one when this is present.
|
||||
const nextRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||
if (spec.record) {
|
||||
rules.push(
|
||||
``,
|
||||
`${nextRuleNumber}. RECORD YOURSELF, first thing, before the wait begins:`,
|
||||
``,
|
||||
` mkdir -p "$(dirname '${spec.record.loopPidPath}')"`,
|
||||
` echo $$ > '${spec.record.loopPidPath}'`,
|
||||
``,
|
||||
` And every time you spawn an agent, append its session id — the \`session_id\` on the`,
|
||||
` \`{"type":"system","subtype":"init"}\` event, which you are already parsing — one per line:`,
|
||||
``,
|
||||
` echo "<session_id>" >> '${spec.record.spawnedPath}'`,
|
||||
``,
|
||||
` This is not bookkeeping. It is the only record of what you are and what you started: the`,
|
||||
` platform cannot find your process, and a spawned agent's id is unrecoverable once it exits.`,
|
||||
` Without it, deleting this commander leaves you running.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
|
||||
// which number it lands on, matching whichever rules already came before it in this particular prompt.
|
||||
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||
const outputRuleNumber = nextRuleNumber + (spec.record ? 1 : 0);
|
||||
rules.push(
|
||||
``,
|
||||
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
|
||||
|
||||
@@ -35,6 +35,13 @@ type BackgroundJobDialogProps = {
|
||||
cwd: string;
|
||||
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
|
||||
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
|
||||
/**
|
||||
* Where a commander's loop must record its pid and what it spawns. Absent for any other chat.
|
||||
*
|
||||
* Passed in rather than derived here: the paths are rooted in the platform's data directory and keyed on
|
||||
* the commander's session id, neither of which a dialog knows.
|
||||
*/
|
||||
recordPaths?: { loopPidPath: string; spawnedPath: string };
|
||||
/**
|
||||
* Is this composer running inside a Command HQ commander?
|
||||
*
|
||||
@@ -55,6 +62,7 @@ export const BackgroundJobDialog = ({
|
||||
cwd,
|
||||
onSubmit,
|
||||
isCommander = false,
|
||||
recordPaths,
|
||||
}: BackgroundJobDialogProps) => {
|
||||
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
||||
const [body, setBody] = useState('');
|
||||
@@ -102,6 +110,10 @@ export const BackgroundJobDialog = ({
|
||||
onFire,
|
||||
duration: timed ? minutes : null,
|
||||
cwd,
|
||||
// Only a commander gets the record instruction: it is what makes stopping and deleting one possible,
|
||||
// and an ordinary chat's background job has no commander to belong to. `record` is undefined until the
|
||||
// session has an id, which is the same moment it becomes a commander — see NewCommander.
|
||||
...(recordPaths ? { record: recordPaths } : {}),
|
||||
};
|
||||
// The intent is what the transcript shows; the preamble rides underneath it.
|
||||
onSubmit(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useE
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
|
||||
import { armBackgroundJob } from '../../../background-job-origin';
|
||||
import { useWorkspace } from '../../../components/Workspace';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
@@ -15,10 +16,15 @@ import { BackgroundTaskTray } from './BackgroundTaskTray';
|
||||
|
||||
type InputAreaProps = {
|
||||
manager: UseEmbeddableChatType;
|
||||
/** When set, input is refused and the reason is shown in place of the composer's own hints. */
|
||||
disabled?: false | { reason: string };
|
||||
};
|
||||
|
||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -106,6 +112,9 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
// Native `disabled`, not a click guard: it also takes the field out of the tab order and stops a
|
||||
// paste, which a guard on send would not.
|
||||
disabled={!!disabled}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
// Enter is how anybody actually sends, so the interception has to be here as well as on the
|
||||
@@ -133,7 +142,10 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
// One placeholder, not two. It was declared twice on this element — the reason above and this
|
||||
// below — and JSX takes the LAST, so the gate was applied and completely invisible: a disabled
|
||||
// composer still inviting you to type a message.
|
||||
placeholder={disabled ? disabled.reason : 'Type a message...'}
|
||||
rows={1}
|
||||
className="min-w-0 flex-1 resize-none rounded-lg border border-input bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
@@ -199,12 +211,17 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
`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.
|
||||
|
||||
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
|
||||
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
|
||||
earlier. `manager.isCommander` is true when either the screen or the session says so.
|
||||
|
||||
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'}
|
||||
isCommander={manager.isCommander}
|
||||
recordPaths={recordPaths}
|
||||
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.
|
||||
|
||||
@@ -107,11 +107,17 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
|
||||
{rows.length === 0 && !isGenerating && (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
|
||||
Send a message to start
|
||||
</div>
|
||||
)}
|
||||
{rows.length === 0 &&
|
||||
!isGenerating &&
|
||||
// `undefined` means nobody said, so keep the default. `null` means "show nothing", which is what
|
||||
// a screen that already explains itself above the transcript passes.
|
||||
(manager.emptyState !== undefined ? (
|
||||
manager.emptyState
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
|
||||
Send a message to start
|
||||
</div>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
<ToolOpenProvider>
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
|
||||
@@ -51,9 +51,11 @@ export function useClaudeUsage(enabled: boolean) {
|
||||
queryKey: KEY,
|
||||
queryFn: () => client.get<UsageResponse>('/chat/usage'),
|
||||
enabled,
|
||||
// The stored rows only change when a turn ends, and a turn ending does not happen while you are reading
|
||||
// this panel. The refresh below is what keeps it honest, not a stale timer.
|
||||
staleTime: 5 * 60_000,
|
||||
// No stale window. This query is only the instant first paint — the refresh below is the real answer
|
||||
// — and a five-minute one meant that after switching account the panel painted the OLD account's
|
||||
// stored row and held it until the refresh landed, which is exactly the moment somebody decides the
|
||||
// numbers are wrong.
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type RecordPaths = { loopPidPath: string; spawnedPath: string };
|
||||
|
||||
/**
|
||||
* Where this commander's loop must record its pid and what it spawns.
|
||||
*
|
||||
* Fetched rather than derived: both are rooted in `DATA_PATH`, a server fact. Cached indefinitely because
|
||||
* they are a pure function of the session id — nothing can change them but a different commander.
|
||||
*
|
||||
* Null for anything that is not a commander, and for a commander that has no id yet. The composer passes
|
||||
* whatever this returns straight into the background-job prompt, so absent means the loop is simply not
|
||||
* asked to record — correct for an ordinary chat, and the reason a commander must have its id before its
|
||||
* first background job.
|
||||
*/
|
||||
export function useCommanderRecordPaths(sessionId: string | null): RecordPaths | undefined {
|
||||
const client = useClient();
|
||||
const { data } = useQuery<RecordPaths>({
|
||||
queryKey: ['command-hq', 'record-paths', sessionId],
|
||||
queryFn: () => client.get<RecordPaths>(`/command-hq/commanders/${sessionId}/record-paths`),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Plus, Radar } from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
@@ -55,23 +55,21 @@ export const CommandHqCategories = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Radar className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Command HQ</div>
|
||||
<div className="truncate text-xs text-muted-foreground">Watchers & long-running jobs</div>
|
||||
</div>
|
||||
</div>
|
||||
/*
|
||||
One scrollable list, and a heading that does not scroll with it.
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
||||
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||
</nav>
|
||||
This was a nav COLUMN with the screen's branding at the top — a 9px Radar tile, "Command HQ", and
|
||||
"Watchers & long-running jobs" — costing about seventy pixels. All three are gone: the panel now sits
|
||||
at the FOOT of the left column rather than owning it, so the vertical room is the whole budget, and the
|
||||
screen's own header already says Command HQ two inches above. Repeating it in a filter panel was
|
||||
branding where a list should be.
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-4 pb-1">
|
||||
`All commanders` and `Uncategorised` are inside the scroll box with the categories rather than in a
|
||||
separate nav above it. They are the same kind of thing — a filter over the list — and splitting them
|
||||
into two nav blocks made the second look like a different mechanism.
|
||||
*/
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/30">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -83,7 +81,9 @@ export const CommandHqCategories = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
||||
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||
{categories.map((category) => (
|
||||
<Row
|
||||
key={category.id}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Check, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { EditableTitle } from '@/components/EditableTitle';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||
import { useChat, EmbeddableChat } from '../Chat';
|
||||
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
|
||||
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
|
||||
// read the route directly, per `docs/navigation-audit.md`):
|
||||
@@ -32,6 +36,158 @@ function EmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instructions shown in an empty new-commander pane.
|
||||
*
|
||||
* Deliberately plain and deliberately unfinished — see `newCommanderGuide.ts`. A `provisional` section
|
||||
* carries a visible marker so a guide that is still being written cannot be mistaken for one that is done,
|
||||
* which matters because this ships on every deploy whether the copy is ready or not.
|
||||
*/
|
||||
const NewCommanderGuide = () => (
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||
{NEW_COMMANDER_GUIDE.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
{section.title}
|
||||
{section.provisional && (
|
||||
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
|
||||
draft
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{section.body.map((paragraph) => (
|
||||
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The whole opening move, as one button.
|
||||
*
|
||||
* Writing a good first prompt is the hard part of starting a commander, and asking somebody to do it before
|
||||
* they have talked to it gets it done badly or not at all. So this sends the opener instead: a brief the
|
||||
* MODEL reads telling it to run the interview, while the transcript shows the short line a person would
|
||||
* have written. That split is `effectivePrompt` vs `displayText`, which the chat path already had.
|
||||
*
|
||||
* The instruction itself is deliberately thin. What to ask, in what order, and to orient in the directory
|
||||
* before asking anything at all, all live in the Commanders skill — which is re-read from disk every run, so
|
||||
* it can be reworded without a deploy. Duplicating any of it here would create a second copy to drift.
|
||||
*/
|
||||
const startBrief = (name: string): string =>
|
||||
[
|
||||
`You have been named "${name}", and that is the only thing I have told you about what I want.`,
|
||||
'It is not decoration — I chose it before anything else, so treat it as the subject and read this',
|
||||
'directory looking for what it refers to.',
|
||||
'',
|
||||
'Read the **Commanders** skill before you reply. It is how this works, and it is short.',
|
||||
'',
|
||||
'You watch for the thing I name and you respond when it happens. You never cause it — not while we',
|
||||
'are setting you up, and not on a first run. If you find work of that kind already waiting here, it',
|
||||
'is mine to start, not yours to volunteer for.',
|
||||
'',
|
||||
'Now: can you tell what the job is from what is written here, or not?',
|
||||
'',
|
||||
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
|
||||
'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
|
||||
'',
|
||||
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
|
||||
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
|
||||
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
|
||||
'',
|
||||
'Take as long as you like reading first. The constraint is on what you say, not on what you do.',
|
||||
].join('\n');
|
||||
|
||||
const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => (
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-start gap-2">
|
||||
<Button onClick={onStart} disabled={!ready} size="lg">
|
||||
Start the conversation
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ready
|
||||
? 'It will look around this directory, then ask you what to watch for.'
|
||||
: `${blockedBy} — then this starts it.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type StartCommanderButtonProps = {
|
||||
ready: boolean;
|
||||
/** What is still missing, phrased to finish the sentence in the hint below the button. */
|
||||
blockedBy: string;
|
||||
onStart: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The two decisions a commander cannot be started without.
|
||||
*
|
||||
* Rendered above the guide rather than in a dialog: a first-time reader learns what a commander is and makes
|
||||
* these choices without leaving the page, and somebody who already knows can fill both in without opening
|
||||
* anything.
|
||||
*
|
||||
* The directory is a button rather than a text field. It is an absolute path on the server and typing one is
|
||||
* how you get a commander running in a directory that does not exist — `DirPickerModal` browses the account's
|
||||
* own tree, which is the same containment the server applies to whatever it is handed.
|
||||
*/
|
||||
const CommanderSetup = ({
|
||||
name,
|
||||
onName,
|
||||
cwd,
|
||||
onPickDirectory,
|
||||
}: {
|
||||
name: string;
|
||||
onName: (next: string) => void;
|
||||
cwd: string | null;
|
||||
onPickDirectory: () => void;
|
||||
}) => (
|
||||
<div className="shrink-0 border-b border-border bg-muted/20 px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="commander-name" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="commander-name"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(ev) => onName(ev.target.value)}
|
||||
placeholder="Platform deploys"
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
{/* Said here because it is not obvious and it is not reversible cheaply: the harness would otherwise
|
||||
name this after whatever the first message happens to say. */}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Applied to the conversation as it starts, instead of the harness naming it after your first message.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Working directory</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickDirectory}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
cwd ? 'border-border bg-background' : 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Where every command it runs resolves from, and where its history is kept. It cannot be moved later.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
||||
*
|
||||
@@ -59,12 +215,76 @@ function NewCommander({
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
|
||||
/*
|
||||
A name and a working directory, both required BEFORE the first message.
|
||||
|
||||
Neither can be set afterwards without cost. The directory decides where every relative path in every turn
|
||||
resolves and which project group holds the transcripts, so changing it later would strand a conversation
|
||||
away from its own history. The name has to reach the transcript the moment it exists — `commanderName` on
|
||||
the turn — because Claude Code names a new session from its first prompt, and the platform's rename only
|
||||
wins by getting there first.
|
||||
|
||||
So the composer is disabled until both are chosen. That is why this lives on the card rather than in a
|
||||
dialog: you read what a commander is and make the two decisions in the same place.
|
||||
*/
|
||||
const [name, setName] = useState('');
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const ready = name.trim().length > 0 && !!cwd;
|
||||
|
||||
const chat = useChat(undefined, undefined, {
|
||||
replaceUrl: false,
|
||||
context: 'command-hq',
|
||||
// The name rides the turn; the DIRECTORY is a prop on EmbeddableChat below, because that is what feeds
|
||||
// `sendPrompt`'s `cwdParam`. Two different paths for two values chosen in the same place, which is worth
|
||||
// a note rather than a discovery.
|
||||
...(name.trim() ? { commanderName: name.trim() } : {}),
|
||||
});
|
||||
// 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);
|
||||
|
||||
/*
|
||||
Has this commander been started — which is NOT the same as "has it any messages".
|
||||
|
||||
`messages.length === 0` was standing in for it, and it lies in exactly the situation that matters.
|
||||
Lose the socket mid-turn and the messages come back empty, so the setup pane and its Start button
|
||||
reappear on a commander that is already running; pressing Start then sends the opening brief a
|
||||
SECOND time, and the agent answers a question nobody asked twice. Seen tonight, after a restart I
|
||||
ran while a commander was live.
|
||||
|
||||
A latch, not a derived value. Once started, always started — the point is to survive the moment the
|
||||
live state goes empty, so anything recomputed from that state is the bug rather than the fix.
|
||||
*/
|
||||
const [started, setStarted] = useState(false);
|
||||
useEffect(() => {
|
||||
if (chat.messages.length > 0 || chat.claudeSessionId || chat.isGenerating) setStarted(true);
|
||||
}, [chat.messages.length, chat.claudeSessionId, chat.isGenerating]);
|
||||
/*
|
||||
Refresh the grid when the first turn ends.
|
||||
|
||||
The commander's NAME is written server-side, into the transcript's summary record, on the first
|
||||
`result` — the rename can only land once the transcript exists. Nothing tells the client that
|
||||
happened, so the list kept whatever title it had fetched before, which for a self-started commander
|
||||
is the opening brief: the pill read "I have given you a name and a working directory…" while the
|
||||
header beside it read the real name from local state.
|
||||
|
||||
The falling edge of `isGenerating` is the signal, rather than a poll or a shorter `staleTime`: it is
|
||||
the one moment the server is known to have written something the list is showing.
|
||||
*/
|
||||
const wasGenerating = useRef(false);
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !chat.isGenerating) {
|
||||
// Both shapes, not just the grid: the header reads the per-commander query and went stale the same
|
||||
// way, so the first attempt at this fixed the pill and left the title beside it still wrong.
|
||||
qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] });
|
||||
qc.invalidateQueries({ queryKey: ['command-hq', 'commander'] });
|
||||
}
|
||||
wasGenerating.current = chat.isGenerating;
|
||||
}, [chat.isGenerating, qc]);
|
||||
|
||||
useEffect(() => {
|
||||
// `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
|
||||
@@ -89,7 +309,15 @@ function NewCommander({
|
||||
{chat.claudeSessionId ? (
|
||||
<MetaBar
|
||||
sessionId={chat.claudeSessionId}
|
||||
title={undefined}
|
||||
/*
|
||||
The name the owner typed, straight from state.
|
||||
|
||||
It cannot come from the server yet: the rename lands at the END of the first turn (the transcript
|
||||
does not exist when the turn starts), and this bar has no fetched title of its own — a brand-new
|
||||
commander has nothing to resume. So the header read "Untitled commander" until a refresh, showing
|
||||
the one thing on screen that nobody had chosen while the chosen name sat in a field above it.
|
||||
*/
|
||||
title={name.trim() || undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onDisconnect={chat.disconnectSession}
|
||||
@@ -103,7 +331,57 @@ function NewCommander({
|
||||
`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" />
|
||||
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
|
||||
message to start", which is true and tells a first-time user nothing about what to send. */}
|
||||
{/*
|
||||
One scroll region for the whole pre-start pane, rather than three `shrink-0` siblings.
|
||||
|
||||
They stacked to whatever height they wanted and nothing could scroll: the guide carried its own
|
||||
`overflow-y-auto`, which never engaged, because `shrink-0` had already given it its full natural
|
||||
height. Fine at 100%, and the panel system is zoomable — at 120% the setup ran off the bottom of
|
||||
the panel with no way to reach it.
|
||||
|
||||
So the scroller is here, where the height is actually constrained, and the composer below keeps
|
||||
`shrink-0` so it stays reachable at the bottom whatever the zoom.
|
||||
*/}
|
||||
{!started && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<CommanderSetup name={name} onName={setName} cwd={cwd} onPickDirectory={() => setPickerOpen(true)} />
|
||||
<StartCommanderButton
|
||||
ready={ready}
|
||||
blockedBy={!name.trim() ? 'Name it first' : 'Choose a working directory'}
|
||||
onStart={() =>
|
||||
// Positional, and the shape is why `opts` was added rather than a tenth slot: displayText is #7.
|
||||
chat.sendPrompt(
|
||||
startBrief(name.trim()),
|
||||
undefined,
|
||||
undefined,
|
||||
cwd ? { path: cwd } : undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'Set me up.',
|
||||
undefined,
|
||||
{ selfStarted: true },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NewCommanderGuide />
|
||||
</div>
|
||||
)}
|
||||
{/* Disabled until both are chosen. The composer is where somebody would otherwise type first and
|
||||
discover the requirement afterwards, having already written what they meant to say. */}
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
sessionId={undefined}
|
||||
context="command-hq"
|
||||
{...(cwd ? { cwd: { path: cwd } } : {})}
|
||||
disabled={
|
||||
!started && !ready ? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' } : false
|
||||
}
|
||||
className={!started ? 'shrink-0' : 'min-h-0 flex-1'}
|
||||
{...(!started ? { emptyState: null } : {})}
|
||||
/>
|
||||
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,16 @@ export const CommandHqGrid = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{/*
|
||||
One per line, full width — a grid of one.
|
||||
|
||||
It was `sm:grid-cols-2 xl:grid-cols-3`, which was reasonable when this panel owned 34% of the screen
|
||||
and is not now that it owns part of a 24% column: two columns of card inside it left each one too
|
||||
narrow for a title, so every card truncated and the cost line wrapped. Nothing about the card changed
|
||||
— same rounded pill, same `p-4`, same `gap-3` between them — it simply stops being asked to share a
|
||||
row.
|
||||
*/}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{commanders.map((commander) => (
|
||||
<Card
|
||||
key={commander.id}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
What the pane says before a new commander has been spoken to.
|
||||
|
||||
── Why this is data, and why it is a skeleton ──
|
||||
|
||||
The creation flow is being rebuilt. Starting a commander currently means knowing to open the background-job
|
||||
dialog and arm a watcher from it, which is not discoverable and reads as configuration when it is really
|
||||
just "talk to it" — so the dialog stops being the way in, and this pane explains the way in instead.
|
||||
|
||||
The copy is NOT written yet, by agreement: the instructions have to describe a flow that does not exist
|
||||
yet, and writing them before the flow is decided produces text that is wrong in a way nobody notices.
|
||||
What is here is the structure plus the parts that are already TRUE regardless of what the flow becomes —
|
||||
what a commander is, and that it begins as an ordinary conversation.
|
||||
|
||||
Sections marked `provisional` render with a visible marker, so a half-written guide cannot ship looking
|
||||
finished. Removing the flag is the last step of writing one, not a formatting choice.
|
||||
*/
|
||||
|
||||
export type GuideSection = {
|
||||
title: string;
|
||||
/** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */
|
||||
body: string[];
|
||||
/** True until the copy is settled. Renders a marker; the whole point is that it is visible. */
|
||||
provisional?: boolean;
|
||||
};
|
||||
|
||||
export const NEW_COMMANDER_GUIDE: GuideSection[] = [
|
||||
{
|
||||
title: 'What a commander is',
|
||||
body: [
|
||||
'An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.',
|
||||
'It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Starting one',
|
||||
provisional: true,
|
||||
body: [
|
||||
'Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.',
|
||||
'TODO: the concrete steps, once the creation flow is settled. This replaces "open the background-job dialog and arm a watcher", which nobody could be expected to guess.',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what
|
||||
// the flow accepts, and the flow is being rebuilt. Written now, all three would need rewriting — and a
|
||||
// wrong example is worse than none, because a user copies it and blames themselves when it does not work.
|
||||
//
|
||||
// Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is
|
||||
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
|
||||
// the boundary is visible.
|
||||
title: 'Examples of a first message',
|
||||
provisional: true,
|
||||
body: [
|
||||
'TODO: two or three example opening prompts, once the creation flow is settled. They are what turns "describe the job" from an instruction into something a first-time user can act on.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'While it runs',
|
||||
provisional: true,
|
||||
body: [
|
||||
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { RfbClient } from "./rfb";
|
||||
|
||||
type SessionStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
window: { width: number; height: number; x: number; y: number } | null;
|
||||
};
|
||||
|
||||
// The framebuffer is bigger than the board — the board sits at 0,0 and the rest is empty desktop.
|
||||
// Rendering into an offscreen canvas the size of the framebuffer and showing only the board's corner
|
||||
// of it is what makes this look like a window rather than a small game adrift in grey.
|
||||
const VISIBLE = { width: 164, height: 226 };
|
||||
|
||||
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
||||
|
||||
export const MinesweeperPanel = () => {
|
||||
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
|
||||
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
|
||||
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
|
||||
// unstable, its mount effect re-ran every render, and each run set state and caused another render:
|
||||
// an endless fetch loop that starved the socket and left the panel saying "connecting…" forever.
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rfbRef = useRef<RfbClient | null>(null);
|
||||
const buttonsRef = useRef(0);
|
||||
const geometryRef = useRef(VISIBLE);
|
||||
|
||||
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const adopt = useCallback((next: SessionStatus) => {
|
||||
// The board is 152x214 on beginner and wider on the other difficulties. Taking the size from
|
||||
// status means the canvas is right on the first paint instead of after the first poll.
|
||||
if (next.window) geometryRef.current = { width: next.window.width, height: next.window.height };
|
||||
setStatus(next);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status"));
|
||||
} catch {
|
||||
setError("Could not reach the server.");
|
||||
}
|
||||
}, [adopt]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {}));
|
||||
} catch {
|
||||
setError("The session would not start. Is wine-minesweeper installed on the server?");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Paint the visible canvas from the offscreen one. Only the board's rectangle is copied, so the
|
||||
// surrounding desktop never reaches the screen.
|
||||
const present = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const offscreen = offscreenRef.current;
|
||||
if (!canvas || !offscreen) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const { width, height } = geometryRef.current;
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
context.drawImage(offscreen, 0, 0, width, height, 0, 0, width, height);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.running) return;
|
||||
|
||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
|
||||
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
|
||||
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
|
||||
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const rfb = new RfbClient(url, {
|
||||
onReady: (width, height) => {
|
||||
const offscreen = document.createElement("canvas");
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
offscreenRef.current = offscreen;
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
},
|
||||
onFrame: (image, x, y) => {
|
||||
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
|
||||
present();
|
||||
},
|
||||
onCopyRect: (from, to) => {
|
||||
const offscreen = offscreenRef.current;
|
||||
const context = offscreen?.getContext("2d");
|
||||
if (!offscreen || !context) return;
|
||||
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
|
||||
present();
|
||||
},
|
||||
onResize: (width, height) => {
|
||||
const offscreen = offscreenRef.current;
|
||||
if (offscreen) {
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
}
|
||||
rfb.requestUpdate(false);
|
||||
},
|
||||
onError: (message) => setError(message),
|
||||
onClose: () => setConnected(false),
|
||||
});
|
||||
|
||||
rfbRef.current = rfb;
|
||||
return () => {
|
||||
rfb.close();
|
||||
rfbRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [status?.running, present]);
|
||||
|
||||
// The board resizes when the difficulty changes, and nothing in RFB announces that — the framebuffer
|
||||
// stays the same size. Polling the window geometry is what keeps the visible canvas matched to it.
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status");
|
||||
if (!next.window) return;
|
||||
const { width, height } = next.window;
|
||||
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
|
||||
geometryRef.current = { width, height };
|
||||
present();
|
||||
}
|
||||
} catch {
|
||||
/* a failed poll is not worth surfacing; the next one will do */
|
||||
}
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [connected, present]);
|
||||
|
||||
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
const scaleX = ev.currentTarget.width / rect.width;
|
||||
const scaleY = ev.currentTarget.height / rect.height;
|
||||
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
|
||||
};
|
||||
|
||||
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
|
||||
const rfb = rfbRef.current;
|
||||
if (!rfb) return;
|
||||
ev.preventDefault();
|
||||
|
||||
if (kind !== "move") {
|
||||
const bit = BUTTON_MASK[ev.button] ?? 0;
|
||||
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
||||
}
|
||||
const { x, y } = toFramebuffer(ev);
|
||||
rfb.pointer(x, y, buttonsRef.current);
|
||||
};
|
||||
|
||||
if (status && !status.installed) {
|
||||
return (
|
||||
<Centred>
|
||||
<Bomb className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
|
||||
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
||||
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
|
||||
</p>
|
||||
</Centred>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.running) {
|
||||
return (
|
||||
<Centred>
|
||||
<Bomb className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
{busy ? "Starting" : "Start it"}
|
||||
</button>
|
||||
</Centred>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={VISIBLE.width}
|
||||
height={VISIBLE.height}
|
||||
onMouseDown={(ev) => onPointer(ev, "down")}
|
||||
onMouseUp={(ev) => onPointer(ev, "up")}
|
||||
onMouseMove={(ev) => onPointer(ev, "move")}
|
||||
onContextMenu={(ev) => ev.preventDefault()}
|
||||
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
|
||||
// photograph of a screen rather than the screen.
|
||||
className="rounded-sm shadow-lg [image-rendering:pixelated]"
|
||||
style={{ cursor: connected ? "default" : "progress" }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{connected ? "connected" : "connecting…"}</span>
|
||||
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
||||
<RefreshCw className="size-3" /> refresh
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Bomb } from 'lucide-react';
|
||||
import { MinesweeperPanel } from './MinesweeperPanel';
|
||||
|
||||
export { MinesweeperPanel } from './MinesweeperPanel';
|
||||
export { RfbClient } from './rfb';
|
||||
|
||||
// Minesweeper X on a virtual display, streamed over RFB. The client is ./rfb.ts — a few hundred lines
|
||||
// rather than a vendored noVNC, because we control the server and need two encodings out of ten.
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'minesweeper', name: 'Minesweeper', icon: Bomb, component: MinesweeperPanel },
|
||||
];
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* A minimal RFB 3.8 client — enough to watch a framebuffer and click on it, and no more.
|
||||
*
|
||||
* ── Why this rather than noVNC ──
|
||||
*
|
||||
* noVNC is the obvious choice and it is a good library. It is also ~200 KB of vendored JavaScript for a
|
||||
* job this file does in a few hundred lines, and vendoring is this repository's reluctant exception
|
||||
* (hls.js, committed unminified with provenance hashes) rather than its habit. What we need is one
|
||||
* server we control, one pixel format we choose, and two encodings. The rest of RFB — the eight other
|
||||
* encodings, the compression, the authentication schemes, the clipboard, the audio — is surface we would
|
||||
* carry and never execute.
|
||||
*
|
||||
* So: Raw and CopyRect, one pixel format, security type None. If this ever has to face a VNC server we
|
||||
* do not configure, that is the moment to reach for noVNC instead of growing this.
|
||||
*
|
||||
* ── The pixel format ──
|
||||
*
|
||||
* The client DICTATES the format via SetPixelFormat rather than accepting the server's, so decoding is
|
||||
* a memcpy into ImageData instead of a per-pixel shift-and-mask. We ask for 32bpp true colour with
|
||||
* big-endian byte order, which puts the bytes on the wire as R,G,B,X — exactly the order a canvas
|
||||
* ImageData buffer wants, so a Raw rectangle copies straight in.
|
||||
*/
|
||||
|
||||
export type RfbEvents = {
|
||||
onReady: (width: number, height: number) => void;
|
||||
onFrame: (image: ImageData, x: number, y: number) => void;
|
||||
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
|
||||
onResize: (width: number, height: number) => void;
|
||||
onError: (message: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ENCODING_RAW = 0;
|
||||
const ENCODING_COPY_RECT = 1;
|
||||
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
|
||||
|
||||
type Phase = 'version' | 'security' | 'security-result' | 'init' | 'running';
|
||||
|
||||
export class RfbClient {
|
||||
private socket: WebSocket;
|
||||
private buffer = new Uint8Array(0);
|
||||
private phase: Phase = 'version';
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private events: RfbEvents;
|
||||
private closed = false;
|
||||
|
||||
constructor(url: string, events: RfbEvents) {
|
||||
this.events = events;
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.binaryType = 'arraybuffer';
|
||||
this.socket.onmessage = (ev) => this.receive(new Uint8Array(ev.data as ArrayBuffer));
|
||||
this.socket.onclose = () => {
|
||||
this.closed = true;
|
||||
this.events.onClose();
|
||||
};
|
||||
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
|
||||
// Uint8Array<ArrayBuffer>, not plain Uint8Array: the default is Uint8Array<ArrayBufferLike>, whose
|
||||
// buffer may be a SharedArrayBuffer, and WebSocket.send takes only BufferSource. Every caller here
|
||||
// builds its own array, so narrowing costs nothing. The terminal relay narrows the same way.
|
||||
private send(bytes: Uint8Array<ArrayBuffer>): void {
|
||||
if (this.socket.readyState === WebSocket.OPEN) this.socket.send(bytes);
|
||||
}
|
||||
|
||||
private receive(chunk: Uint8Array): void {
|
||||
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
||||
merged.set(this.buffer);
|
||||
merged.set(chunk, this.buffer.length);
|
||||
this.buffer = merged;
|
||||
|
||||
// Each step consumes a whole message or nothing at all, so a message split across TCP chunks simply
|
||||
// waits for the rest. Returning false means "not enough bytes yet".
|
||||
while (!this.closed && this.step()) {
|
||||
/* keep draining */
|
||||
}
|
||||
}
|
||||
|
||||
private take(n: number): Uint8Array | null {
|
||||
if (this.buffer.length < n) return null;
|
||||
const head = this.buffer.subarray(0, n);
|
||||
this.buffer = this.buffer.subarray(n);
|
||||
return head;
|
||||
}
|
||||
|
||||
private step(): boolean {
|
||||
switch (this.phase) {
|
||||
case 'version':
|
||||
return this.stepVersion();
|
||||
case 'security':
|
||||
return this.stepSecurity();
|
||||
case 'security-result':
|
||||
return this.stepSecurityResult();
|
||||
case 'init':
|
||||
return this.stepInit();
|
||||
case 'running':
|
||||
return this.stepMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private stepVersion(): boolean {
|
||||
const head = this.take(12);
|
||||
if (!head) return false;
|
||||
// Answer 3.8 regardless of what the server offered; x11vnc speaks it and anything older is a server
|
||||
// we are not configuring.
|
||||
this.send(new TextEncoder().encode('RFB 003.008\n'));
|
||||
this.phase = 'security';
|
||||
return true;
|
||||
}
|
||||
|
||||
private stepSecurity(): boolean {
|
||||
if (this.buffer.length < 1) return false;
|
||||
const count = this.buffer[0] as number;
|
||||
if (count === 0) {
|
||||
// A zero count means the server is refusing us, and the reason follows as a string.
|
||||
const body = this.take(1 + 4);
|
||||
if (!body) return false;
|
||||
this.events.onError('The VNC server refused the connection.');
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
const head = this.take(1 + count);
|
||||
if (!head) return false;
|
||||
|
||||
const types = Array.from(head.subarray(1));
|
||||
if (!types.includes(1)) {
|
||||
this.events.onError('The VNC server wants authentication, which this client does not implement.');
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
this.send(new Uint8Array([1])); // None — the relay is loopback-only and Officer is the authenticator
|
||||
this.phase = 'security-result';
|
||||
return true;
|
||||
}
|
||||
|
||||
private stepSecurityResult(): boolean {
|
||||
const head = this.take(4);
|
||||
if (!head) return false;
|
||||
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
|
||||
this.events.onError('The VNC server rejected the handshake.');
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
this.send(new Uint8Array([1])); // shared: do not disconnect other viewers
|
||||
this.phase = 'init';
|
||||
return true;
|
||||
}
|
||||
|
||||
private stepInit(): boolean {
|
||||
if (this.buffer.length < 24) return false;
|
||||
const nameLength = new DataView(this.buffer.buffer, this.buffer.byteOffset + 20, 4).getUint32(0);
|
||||
const head = this.take(24 + nameLength);
|
||||
if (!head) return false;
|
||||
|
||||
const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
|
||||
this.width = view.getUint16(0);
|
||||
this.height = view.getUint16(2);
|
||||
|
||||
this.setPixelFormat();
|
||||
this.setEncodings();
|
||||
this.phase = 'running';
|
||||
this.events.onReady(this.width, this.height);
|
||||
this.requestUpdate(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 32bpp true colour, big-endian, shifts 16/8/0 — i.e. R,G,B,X on the wire, which is ImageData order. */
|
||||
private setPixelFormat(): void {
|
||||
const message = new Uint8Array(20);
|
||||
const view = new DataView(message.buffer);
|
||||
message[0] = 0; // SetPixelFormat
|
||||
message[4] = 32; // bits per pixel
|
||||
message[5] = 24; // depth
|
||||
message[6] = 1; // big-endian
|
||||
message[7] = 1; // true colour
|
||||
view.setUint16(8, 255); // red max
|
||||
view.setUint16(10, 255); // green max
|
||||
view.setUint16(12, 255); // blue max
|
||||
message[14] = 16; // red shift
|
||||
message[15] = 8; // green shift
|
||||
message[16] = 0; // blue shift
|
||||
this.send(message);
|
||||
}
|
||||
|
||||
private setEncodings(): void {
|
||||
const encodings = [ENCODING_COPY_RECT, ENCODING_RAW, PSEUDO_ENCODING_DESKTOP_SIZE];
|
||||
const message = new Uint8Array(4 + encodings.length * 4);
|
||||
const view = new DataView(message.buffer);
|
||||
message[0] = 2; // SetEncodings
|
||||
view.setUint16(2, encodings.length);
|
||||
encodings.forEach((encoding, i) => view.setInt32(4 + i * 4, encoding));
|
||||
this.send(message);
|
||||
}
|
||||
|
||||
requestUpdate(incremental = true): void {
|
||||
const message = new Uint8Array(10);
|
||||
const view = new DataView(message.buffer);
|
||||
message[0] = 3; // FramebufferUpdateRequest
|
||||
message[1] = incremental ? 1 : 0;
|
||||
view.setUint16(6, this.width);
|
||||
view.setUint16(8, this.height);
|
||||
this.send(message);
|
||||
}
|
||||
|
||||
private stepMessage(): boolean {
|
||||
if (this.buffer.length < 1) return false;
|
||||
const type = this.buffer[0] as number;
|
||||
|
||||
// 0 FramebufferUpdate, 1 SetColourMapEntries, 2 Bell, 3 ServerCutText. Only the first matters here;
|
||||
// the others still have to be CONSUMED or the stream desynchronises and every later frame is garbage.
|
||||
if (type === 0) return this.stepFramebufferUpdate();
|
||||
if (type === 2) return this.take(1) !== null;
|
||||
if (type === 1) {
|
||||
if (this.buffer.length < 6) return false;
|
||||
const count = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 2).getUint16(0);
|
||||
return this.take(6 + count * 6) !== null;
|
||||
}
|
||||
if (type === 3) {
|
||||
if (this.buffer.length < 8) return false;
|
||||
const length = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 4).getUint32(0);
|
||||
return this.take(8 + length) !== null;
|
||||
}
|
||||
|
||||
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed without consuming anything until the WHOLE update is present. A partial rectangle cannot be
|
||||
* drawn and cannot be put back, so the alternative is a second parser for resumed state.
|
||||
*/
|
||||
private stepFramebufferUpdate(): boolean {
|
||||
if (this.buffer.length < 4) return false;
|
||||
const rectangles = new DataView(this.buffer.buffer, this.buffer.byteOffset + 2, 2).getUint16(0);
|
||||
|
||||
let offset = 4;
|
||||
type Rect = { x: number; y: number; w: number; h: number; encoding: number; at: number };
|
||||
const rects: Rect[] = [];
|
||||
|
||||
for (let i = 0; i < rectangles; i++) {
|
||||
if (this.buffer.length < offset + 12) return false;
|
||||
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
|
||||
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
|
||||
offset += 12;
|
||||
|
||||
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
|
||||
else if (rect.encoding === ENCODING_COPY_RECT) offset += 4;
|
||||
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
|
||||
/* no payload — the rect's w/h ARE the new size */
|
||||
} else {
|
||||
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.buffer.length < offset) return false;
|
||||
rects.push(rect);
|
||||
}
|
||||
|
||||
for (const rect of rects) {
|
||||
if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
|
||||
this.width = rect.w;
|
||||
this.height = rect.h;
|
||||
this.events.onResize(rect.w, rect.h);
|
||||
continue;
|
||||
}
|
||||
if (rect.encoding === ENCODING_COPY_RECT) {
|
||||
const src = new DataView(this.buffer.buffer, this.buffer.byteOffset + rect.at, 4);
|
||||
this.events.onCopyRect({ x: src.getUint16(0), y: src.getUint16(2) }, rect);
|
||||
continue;
|
||||
}
|
||||
// Raw: the wire bytes are already R,G,B,X, so this is a copy rather than a conversion. The alpha
|
||||
// byte is whatever the server sent, so force it opaque.
|
||||
const pixels = new Uint8ClampedArray(this.buffer.subarray(rect.at, rect.at + rect.w * rect.h * 4));
|
||||
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
|
||||
this.events.onFrame(new ImageData(pixels, rect.w, rect.h), rect.x, rect.y);
|
||||
}
|
||||
|
||||
this.take(offset);
|
||||
|
||||
// One request per update message, issued once the whole message is drawn. Requesting per
|
||||
// rectangle would send several for one update, and requesting on a timer would queue updates the
|
||||
// client has not consumed yet — the classic way a VNC view ends up lagging behind the pointer.
|
||||
this.requestUpdate(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** buttons is an RFB button mask: bit 0 left, bit 1 middle, bit 2 right. */
|
||||
pointer(x: number, y: number, buttons: number): void {
|
||||
const message = new Uint8Array(6);
|
||||
const view = new DataView(message.buffer);
|
||||
message[0] = 5; // PointerEvent
|
||||
message[1] = buttons;
|
||||
view.setUint16(2, Math.max(0, Math.min(this.width - 1, Math.round(x))));
|
||||
view.setUint16(4, Math.max(0, Math.min(this.height - 1, Math.round(y))));
|
||||
this.send(message);
|
||||
}
|
||||
|
||||
key(keysym: number, down: boolean): void {
|
||||
const message = new Uint8Array(8);
|
||||
const view = new DataView(message.buffer);
|
||||
message[0] = 4; // KeyEvent
|
||||
message[1] = down ? 1 : 0;
|
||||
view.setUint32(4, keysym);
|
||||
this.send(message);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
@@ -24,6 +26,15 @@ type UsePiChatOptions = {
|
||||
projectScoped?: boolean;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/**
|
||||
* The name chosen for a COMMANDER before its first message.
|
||||
*
|
||||
* Rides every turn rather than just the first: the server applies it once, in `fileAsCommander`, and
|
||||
* sending it again costs a few bytes. Sending it only on the first turn would mean losing it if that turn
|
||||
* failed and was retried, which is the case that matters — a commander named after its second prompt is
|
||||
* the bug this exists to prevent.
|
||||
*/
|
||||
commanderName?: string;
|
||||
resumeSummary?: string;
|
||||
resumeSessionId?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
@@ -43,11 +54,23 @@ type UsePiChatOptions = {
|
||||
* contradict it — which is what a hand-edited or stale `?cwd=` used to do. Anything else in the query
|
||||
* string is left alone.
|
||||
*/
|
||||
function writePermalink(claudeSessionId: string): void {
|
||||
/**
|
||||
* The permalink, as a ROUTER navigation rather than a raw history write.
|
||||
*
|
||||
* It was `window.history.replaceState`, which changes the address bar and tells react-router nothing. Every
|
||||
* consumer that reads the URL through the router therefore never saw it: on the /chat screen the title and
|
||||
* the rename control both come from `useParams().sessionId` (see `ChatDetailPanel`), so a new conversation
|
||||
* stayed "New chat" and could not be renamed until something else forced the router to re-read the
|
||||
* location — which is exactly what navigating away and back did.
|
||||
*
|
||||
* `replace`, not push, so the browser Back button still leaves the conversation rather than stepping through
|
||||
* the same one under a different name.
|
||||
*/
|
||||
function permalinkTo(claudeSessionId: string): { pathname: string; search: string } {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('cwd');
|
||||
const search = params.toString();
|
||||
window.history.replaceState(null, '', `/chat/${claudeSessionId}${search ? `?${search}` : ''}`);
|
||||
return { pathname: `/chat/${claudeSessionId}`, search: search ? `?${search}` : '' };
|
||||
}
|
||||
|
||||
export function useChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
|
||||
@@ -59,6 +82,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
projectScoped,
|
||||
context,
|
||||
contextId,
|
||||
commanderName,
|
||||
resumeSummary: initialResumeSummary,
|
||||
resumeSessionId,
|
||||
initialMessages: preloadedMessages,
|
||||
@@ -200,6 +224,41 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/*
|
||||
Written through the router so `useParams` updates. A raw `history.replaceState` moves the address bar and
|
||||
leaves react-router believing the location is unchanged — see `permalinkTo`.
|
||||
|
||||
In a ref because the message handler is re-created per render and closes over whatever it had; a stable
|
||||
callback keeps the socket's copy valid.
|
||||
*/
|
||||
const writePermalink = useCallback(
|
||||
(claudeSessionId: string) => {
|
||||
navigate(permalinkTo(claudeSessionId), { replace: true });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
/*
|
||||
Tell the session LISTS that this conversation exists.
|
||||
|
||||
The chat header resolves its own title by finding this session in the cached list —
|
||||
`sessions.find((s) => s.id === sessionId)`. A newborn conversation is not in that list: it has
|
||||
`staleTime: 30s` and nothing invalidated it, so for up to half a minute the header read "New chat" and
|
||||
the rename control was absent, because rename is a per-row action on a list the session was not in yet.
|
||||
Navigating away and back fixed it, which is what made it look like a rendering bug rather than a stale
|
||||
cache.
|
||||
|
||||
Fired when the transcript id arrives — the START of the turn, not the end — because that is the moment
|
||||
the session becomes real on disk and the server can already name it from the first user message. Fired
|
||||
again when a turn finishes, because the harness writes its own `ai-title` while the turn runs.
|
||||
*/
|
||||
const announceSession = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['CLAUDE_SESSIONS'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
|
||||
}, [queryClient]);
|
||||
|
||||
// Fetch the next older window and prepend it. The scroll container restores its position from the
|
||||
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
|
||||
@@ -242,9 +301,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
});
|
||||
}
|
||||
|
||||
function commitStreaming() {
|
||||
if (!streamingRef.current) return;
|
||||
const text = streamingRef.current;
|
||||
/**
|
||||
* Turn the in-flight streaming buffer into a committed message.
|
||||
*
|
||||
* `authoritative` is the complete text from the `assistant:text` event that ends the stream, and it
|
||||
* wins whenever it is present. The buffer is an accumulation of deltas and is only ever as good as the
|
||||
* socket: lose one frame — a restart mid-turn will do it — and it is short by whatever that frame
|
||||
* carried, while the event carries the whole message. Committing the buffer threw away a correct copy
|
||||
* in favour of a damaged one, which is how a reply rendered as "ipped." with its first two characters
|
||||
* missing while the durable log held it intact.
|
||||
*/
|
||||
function commitStreaming(authoritative?: string) {
|
||||
if (!streamingRef.current && !authoritative) return;
|
||||
const text = authoritative ?? streamingRef.current;
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
|
||||
@@ -343,6 +412,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
claudeSessionIdRef.current = msg.claudeSessionId;
|
||||
setClaudeSessionId(msg.claudeSessionId);
|
||||
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
||||
// The lists do not know this conversation exists yet — see `announceSession`.
|
||||
announceSession();
|
||||
break;
|
||||
|
||||
case 'assistant:delta':
|
||||
@@ -366,7 +437,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
break;
|
||||
}
|
||||
if (streamingRef.current) {
|
||||
commitStreaming();
|
||||
commitStreaming(msg.text);
|
||||
} else {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text: msg.text }]);
|
||||
}
|
||||
@@ -420,6 +491,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
const hadTools = toolCallsInTurnRef.current;
|
||||
toolCallsInTurnRef.current = false;
|
||||
onTurnCompleteRef.current?.(hadTools);
|
||||
// Every turn end: the harness writes its own `ai-title` while a turn runs, so the name the lists are
|
||||
// showing may now be bettered by the one on disk.
|
||||
announceSession();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -728,6 +802,20 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
* exact message that starts the watching turn, not a later one.
|
||||
*/
|
||||
armWatcher?: boolean,
|
||||
/**
|
||||
* Anything else this turn needs to say about ITSELF rather than about its content.
|
||||
*
|
||||
* An object rather than a tenth positional: nine is already past the point where a call site reads as
|
||||
* a row of undefineds, and every flag added here would make that worse for every existing caller.
|
||||
*/
|
||||
opts?: {
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opening message, so this turn is a request for the
|
||||
* commander to interview them — see `commanderBlock`. Only the client that rendered the button can
|
||||
* know this, which is why it rides the message rather than being derived on the server.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
},
|
||||
) {
|
||||
// Mark session as started on first message
|
||||
if (!hasStarted) {
|
||||
@@ -768,6 +856,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
type: 'chat',
|
||||
prompt: text,
|
||||
...(displayText ? { displayText } : {}),
|
||||
...(opts?.selfStarted ? { selfStarted: true } : {}),
|
||||
sessionId: sessionIdRef.current,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||
@@ -780,6 +869,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(context ? { context } : {}),
|
||||
...(contextId ? { contextId } : {}),
|
||||
...(commanderName ? { commanderName } : {}),
|
||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
...(armWatcher ? { armWatcher } : {}),
|
||||
|
||||
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
|
||||
title?: string | null;
|
||||
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
|
||||
partCount?: number;
|
||||
/**
|
||||
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
|
||||
*
|
||||
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
|
||||
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
|
||||
* to decide it from the route, so the same commander opened in /chat quietly lost it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
};
|
||||
|
||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||
|
||||
@@ -27,7 +27,8 @@ export type LiveSession = {
|
||||
title: string | null;
|
||||
cwd: string | null;
|
||||
/**
|
||||
* A commander rather than an ordinary chat, derived server-side from the cwd.
|
||||
* A commander rather than an ordinary chat, derived server-side from its `command_hq_sessions` row.
|
||||
* NOT from the cwd — a commander chooses its own working directory, so the directory answers nothing.
|
||||
*
|
||||
* 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
|
||||
@@ -36,6 +37,12 @@ export type LiveSession = {
|
||||
isCommander: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts describe every live session, not just the ones returned — so a filtered request still renders
|
||||
* `Agents (n) / Commanders (n)` without a second round trip.
|
||||
*/
|
||||
type LivePayload = { sessions: LiveSession[]; counts: { agents: number; commanders: number } };
|
||||
|
||||
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';
|
||||
|
||||
/**
|
||||
@@ -50,9 +57,17 @@ export function useLiveSessions() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<{ sessions: LiveSession[] }>({
|
||||
/*
|
||||
`kind=all` is required, not a default. `/chat/live` returns ONLY agents unless asked otherwise, so that
|
||||
a caller which forgets can never be handed a commander — a phone swiping between live chats one at a
|
||||
time has no way to skip one. This list is the other case: it renders both kinds as tabs, so it asks for
|
||||
both, explicitly, and the explicitness is what makes it reviewable.
|
||||
|
||||
`counts` describes the whole set either way, which is why both tab numbers are right from one request.
|
||||
*/
|
||||
const { data, isLoading, error, refetch } = useQuery<LivePayload>({
|
||||
queryKey: [LIVE_KEY],
|
||||
queryFn: () => client.get<{ sessions: LiveSession[] }>('/chat/live'),
|
||||
queryFn: () => client.get<LivePayload>('/chat/live?kind=all'),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
|
||||
@@ -9,3 +9,5 @@ export { useThemeAsset } from './useThemeAsset';
|
||||
export { useThemeKeys } from './useThemeKeys';
|
||||
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
|
||||
export type { Theme, ThemeId } from './themes';
|
||||
// Ordering helpers for the pickers — pure, and tested in rotate.test.ts.
|
||||
export { rotateToSelected, favouritesFirst, groupByFamily } from './rotate';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from './rotate';
|
||||
|
||||
const ids = (list: Array<{ id: string }>) => list.map((t) => t.id);
|
||||
const list = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
|
||||
|
||||
describe('rotating to the selected theme', () => {
|
||||
test('puts the selected one first and wraps the rest behind it', () => {
|
||||
expect(ids(rotateToSelected(list, 'c'))).toEqual(['c', 'd', 'a', 'b']);
|
||||
});
|
||||
|
||||
// The property that makes this a rotation rather than a sort: nobody loses a neighbour.
|
||||
test('every adjacency survives, only the starting point moves', () => {
|
||||
const rotated = ids(rotateToSelected(list, 'c'));
|
||||
const doubled = [...ids(list), ...ids(list)].join(',');
|
||||
expect(doubled).toContain(rotated.join(','));
|
||||
});
|
||||
|
||||
test('leaves the order alone when the selected one is already first', () => {
|
||||
expect(ids(rotateToSelected(list, 'a'))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
// An id from an uninstalled theme, or none chosen yet. Rotating by a guess would be worse than not.
|
||||
test('leaves the order alone for an unknown or missing id', () => {
|
||||
expect(ids(rotateToSelected(list, 'gone'))).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(ids(rotateToSelected(list, undefined))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('does not mutate the input', () => {
|
||||
const original = [...ids(list)];
|
||||
rotateToSelected(list, 'd');
|
||||
expect(ids(list)).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('favourites first', () => {
|
||||
test('lifts favourites, keeping the order within each half', () => {
|
||||
expect(ids(favouritesFirst(list, new Set(['d', 'b'])))).toEqual(['b', 'd', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('changes nothing when there are none', () => {
|
||||
expect(ids(favouritesFirst(list, new Set()))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
// Composed with the rotation: the theme you are wearing leads its half.
|
||||
test('composes with the rotation', () => {
|
||||
expect(ids(favouritesFirst(rotateToSelected(list, 'c'), new Set(['c', 'a'])))).toEqual(['c', 'a', 'd', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouping by family', () => {
|
||||
const themed = [
|
||||
{ id: 'duck-dark', name: 'Duck Dark', family: 'Duck' },
|
||||
{ id: 'cat', name: 'Catppuccin', family: 'Catppuccin' },
|
||||
{ id: 'duck-light', name: 'Duck Light', family: 'Duck' },
|
||||
] as never as Parameters<typeof groupByFamily>[0];
|
||||
|
||||
test('keeps families in first-seen order, so a rotation decides them', () => {
|
||||
expect(groupByFamily(themed).map(([family]) => family)).toEqual(['Duck', 'Catppuccin']);
|
||||
});
|
||||
|
||||
// Members of a family separated by the rotation still collect together — the alternative is the same
|
||||
// family heading appearing twice.
|
||||
test('collects a family split by the rotation', () => {
|
||||
const groups = groupByFamily(themed);
|
||||
expect(groups[0]![1].map((t) => t.id)).toEqual(['duck-dark', 'duck-light']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Theme, ThemeId } from './themes';
|
||||
|
||||
/**
|
||||
* The theme list, rotated so the one you are wearing is first.
|
||||
*
|
||||
* With 200+ themes the list is far taller than any container, and the theme you have on is the anchor for
|
||||
* every decision you are about to make — "what did I just come from", "what is next to this". Sorting the
|
||||
* selected one to the top would answer the first question and destroy the second, because it takes that
|
||||
* theme out of its family and leaves a hole where it was.
|
||||
*
|
||||
* A ROTATION keeps every neighbour: the order is unchanged, the starting point moves. Duck Dark's
|
||||
* neighbours are still Duck Light and whatever followed it; they have simply wrapped around behind you.
|
||||
*
|
||||
* The registry order is the ordering to preserve — families sit together in it, and that grouping is the
|
||||
* only structure a list of two hundred names has.
|
||||
*/
|
||||
export function rotateToSelected<T extends { id: ThemeId }>(list: readonly T[], selected: ThemeId | undefined): T[] {
|
||||
if (!selected) return [...list];
|
||||
const at = list.findIndex((entry) => entry.id === selected);
|
||||
// Not present: an id from a theme that has been uninstalled, or none set yet. Leave the order alone
|
||||
// rather than rotate by a guess.
|
||||
if (at <= 0) return [...list];
|
||||
return [...list.slice(at), ...list.slice(0, at)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Favourites first, then everything else — each half keeping the rotation it was given.
|
||||
*
|
||||
* Two-tier rather than a filter, because hiding the rest makes the list unable to answer "what else is
|
||||
* there". A filter is a separate, explicit act; this is the default shape.
|
||||
*/
|
||||
export function favouritesFirst<T extends { id: ThemeId }>(list: readonly T[], favourites: ReadonlySet<ThemeId>): T[] {
|
||||
if (favourites.size === 0) return [...list];
|
||||
const starred: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const entry of list) (favourites.has(entry.id) ? starred : rest).push(entry);
|
||||
return [...starred, ...rest];
|
||||
}
|
||||
|
||||
/** Grouped by family for the dropdown, over an already-ordered list. Families appear in first-seen order. */
|
||||
export function groupByFamily(list: readonly Theme[]): Array<[string, Theme[]]> {
|
||||
const groups = new Map<string, Theme[]>();
|
||||
for (const theme of list) {
|
||||
const existing = groups.get(theme.family);
|
||||
if (existing) existing.push(theme);
|
||||
else groups.set(theme.family, [theme]);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { ThemeId } from './themes';
|
||||
* ctrl+shift+→ next theme, from anywhere, no UI
|
||||
* ctrl+shift+← previous theme
|
||||
* ctrl+shift+space open the list, then browse it
|
||||
* ctrl+shift+f star or unstar the current theme, from anywhere
|
||||
*
|
||||
* One modifier for all three, which took some choosing. `ctrl+alt+arrows` switches WORKSPACE on GNOME and
|
||||
* `ctrl+alt+l` locks the screen, so ctrl+alt is unusable for this. `shift+alt` is free on Linux but is the
|
||||
@@ -20,6 +21,8 @@ import type { ThemeId } from './themes';
|
||||
* action invited the guess that `ctrl+shift+j` moved down — which opens the browser console instead. One
|
||||
* spelling, no guessing.
|
||||
*
|
||||
* Inside the list `f` stars or unstars what is FOCUSED, so a browse and a shortlist are one pass.
|
||||
*
|
||||
* Inside the list the vim keys stay, UNMODIFIED — `j`/`k`/`l`/`h` alongside the arrows. No modifier means
|
||||
* no collision with anything the browser owns, and this is a vim person's application.
|
||||
*
|
||||
@@ -38,8 +41,22 @@ import type { ThemeId } from './themes';
|
||||
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
|
||||
* because the behaviour belongs to themes, while a dialog would drag a component library in behind it.
|
||||
*/
|
||||
export function useThemeKeys() {
|
||||
type ThemeKeysOptions = {
|
||||
/**
|
||||
* Star or unstar the focused theme — bound to `f` while the list is open.
|
||||
*
|
||||
* Passed in rather than owned here: favourites are per ACCOUNT and live behind an API, which is app
|
||||
* knowledge. This hook owns keys and selection and nothing else.
|
||||
*/
|
||||
onToggleFavorite?: (themeId: ThemeId) => void;
|
||||
};
|
||||
|
||||
export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
const { themeId, setTheme, themes } = useTheme();
|
||||
// A ref so the window listener never needs re-binding when the callback identity changes — which it does
|
||||
// on every render, since it closes over a mutation.
|
||||
const onToggleFavorite = useRef(options.onToggleFavorite);
|
||||
onToggleFavorite.current = options.onToggleFavorite;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState<ThemeId>(themeId);
|
||||
const startedOn = useRef<ThemeId>(themeId);
|
||||
@@ -61,10 +78,24 @@ export function useThemeKeys() {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const { themeId: current, focused: focus, open: isOpen } = state.current;
|
||||
|
||||
// Toggle the list. `ctrl+alt+space` still works — it is what this shipped with an hour ago and
|
||||
// fingers are quicker to learn than to unlearn.
|
||||
// Toggle the list. `ctrl+alt+space` is still accepted alongside `ctrl+shift+space` — it is what this
|
||||
// shipped with first, and fingers are quicker to learn than to unlearn.
|
||||
if (event.ctrlKey && (event.shiftKey || event.altKey) && event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
/*
|
||||
Two problems solved by two lines, and both were reported from the same keystroke.
|
||||
|
||||
`stopPropagation` stops the header's dropdown ALSO opening. The theme pill is a Radix
|
||||
DropdownMenuTrigger, and a focused trigger opens on Space — so if the pill happened to have focus,
|
||||
the chord opened the overlay and the menu behind it. React attaches its handlers at the root, which
|
||||
bubbles before window, so the trigger saw the key first no matter what this listener did with it.
|
||||
This listener now runs in the CAPTURE phase and stops the event dead.
|
||||
|
||||
The blur stops the overlay opening with focus still in a text field, which made j/k/l do nothing:
|
||||
the guard below correctly refuses to steal keys from an input, and the input never lost focus.
|
||||
*/
|
||||
event.stopPropagation();
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
setOpen((was) => {
|
||||
if (!was) {
|
||||
startedOn.current = current;
|
||||
@@ -83,6 +114,22 @@ export function useThemeKeys() {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
|
||||
|
||||
/*
|
||||
Star or unstar the CURRENT theme, from anywhere, list open or not.
|
||||
|
||||
Not behind the `typing` guard, unlike the arrows beside it, and the difference is the point: word
|
||||
selection is what `ctrl+shift+←/→` collides with inside a text field, and `ctrl+shift+F` collides
|
||||
with nothing — it types no character and no browser claims it. Guarding it would only mean the
|
||||
binding fails in the composer, which is where you are when a theme annoys you.
|
||||
*/
|
||||
if (event.ctrlKey && event.shiftKey && (event.key === 'f' || event.key === 'F')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// The one you are WEARING, not the one focused in the list — this fires with no list open.
|
||||
onToggleFavorite.current?.(current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle from anywhere, list open or not. Arrows only — see the note above.
|
||||
if (event.ctrlKey && event.shiftKey && !typing) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
@@ -97,9 +144,18 @@ export function useThemeKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen || typing) return;
|
||||
// Only the GLOBAL bindings above defer to a text field. Once the list is open there is no input in
|
||||
// it, focus was blurred to open it, and a `typing` check here is what made every vim key dead when
|
||||
// the overlay was opened from the composer.
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'f':
|
||||
case 'F':
|
||||
// Star or unstar what is focused, without moving or applying it.
|
||||
event.preventDefault();
|
||||
onToggleFavorite.current?.(focus);
|
||||
return;
|
||||
case 'j':
|
||||
case 'J':
|
||||
case 'ArrowDown':
|
||||
@@ -140,8 +196,10 @@ export function useThemeKeys() {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
// CAPTURE, so the chord is claimed before React's root-level handlers see it — see the note above about
|
||||
// the dropdown trigger opening alongside the overlay.
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [setTheme, step]);
|
||||
|
||||
return { open, setOpen, focused, setFocused };
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: background-jobs/a-commander-picks-the-cheapest-model-that-will
|
||||
title: 'A commander picks the cheapest model that will do the job — for itself and for what it spawns'
|
||||
theme: background-jobs
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 2
|
||||
---
|
||||
|
||||
**Before it arms anything, a commander should weigh how hard the job actually is and say which model each
|
||||
half should run on.** Wanted 2026-08-22, for later.
|
||||
|
||||
**Two models, not one**, and this is the part that makes it worth doing rather than a generic "use a cheaper
|
||||
model" note. A commander has two quite different workloads:
|
||||
|
||||
- **the commander's own turns** — agreeing the job with the owner, writing a shell loop, reporting. Mostly
|
||||
conversation and one piece of bash.
|
||||
- **the agent the loop SPAWNS when it fires** — which does the actual work, and whose difficulty is set by
|
||||
the job rather than by the conversation.
|
||||
|
||||
Those can differ by a lot in the same commander. The deploy commander is the worked example: its own turns
|
||||
ran on Sonnet and were unremarkable, while the spawned agent judged six hosts, parsed a plan, decided what to
|
||||
skip and wrote an honest report. A watcher that pings a URL and echoes the status needs neither to be clever.
|
||||
|
||||
**Inferred from the job, and SUGGESTED rather than imposed.** The commander proposes ("this looks like Haiku
|
||||
for the watch and Sonnet for the action, because…") and the owner overrides. A model chosen silently is one
|
||||
nobody can argue with, and the failure mode of choosing too small is not a slower answer — it is a watcher
|
||||
that fires on the wrong condition, or an agent that half-finishes a deploy.
|
||||
|
||||
**Where it goes:** a section in the Commanders skill, beside the four shaping questions, since the moment to
|
||||
ask it is the same moment — while the job is being agreed and before anything is armed. It is one more thing
|
||||
to settle in the conversation the skill already describes.
|
||||
|
||||
**What would make it honest rather than a guess**, and the reason this is not urgent: nothing currently
|
||||
measures whether the choice was right. The cost of a spawned agent is not recorded at all yet
|
||||
(`command_hq_spawned.total_usd` exists and is always null), so a commander suggesting Haiku has no way to
|
||||
learn it was wrong. Worth doing after cost accounting, or at least knowing that until then this is a
|
||||
judgement with no feedback loop.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: infra/revisit-the-bun-1-3-pin-once-1-4-has-been
|
||||
title: 'Revisit the bun 1.3 pin once 1.4 has been watched for a while'
|
||||
theme: infra
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 6
|
||||
---
|
||||
|
||||
**Revisit the bun 1.3 pin once 1.4 has been watched for a while.** Raised by the owner 2026-08-22,
|
||||
who asked for it explicitly as a temporary hold — "a few months, until I'm sure the new 1.4 is going
|
||||
to be safe" — so this exists to stop a deliberate pause becoming permanent by nobody remembering it.
|
||||
|
||||
bun 1.4.0 was published 2026-08-20 and `https://bun.sh/install` serves whatever is newest, so a
|
||||
machine provisioned on 2026-08-22 would have put a two-day-old release under the platform and every
|
||||
pm2 app except `officer-pty`. The hold is at the SERIES, not a patch: `BUN_SERIES=1.3` in
|
||||
`scripts/setup/machine-setup/lib/dev.sh`, resolved to the newest `1.3.x` from the npm registry at
|
||||
install time, so bug fixes inside the series still land.
|
||||
|
||||
Two places carry the number and they must move together — `BUN_SERIES` in `dev.sh`, and
|
||||
`engines.bun` in `package.json`, which is what `bun run doctor` reads to decide whether the running
|
||||
bun has drifted. bun itself ignores `engines` (verified: an install declaring `1.9.x` ran clean under
|
||||
1.3.14), so that field is a declaration and doctor is the enforcement.
|
||||
|
||||
What is NOT covered, and is the reason a drift check exists at all: `bun upgrade` takes no version
|
||||
argument — latest-stable or `--canary`, nothing else — so it always jumps to the newest release and
|
||||
walks a pinned machine off the pin silently. Nothing in a setup script can prevent that; `bun run
|
||||
doctor` reports it after the fact, and `machine-setup.sh --only 'JavaScript runtimes'` offers to pin
|
||||
it back down.
|
||||
|
||||
To lift the hold: raise both numbers, run `bun run doctor` on each machine, and delete this item.
|
||||
@@ -2,7 +2,7 @@
|
||||
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
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 4
|
||||
@@ -28,3 +28,16 @@ closes this and makes the same thing work in any future surface that renders a c
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
**Fixed 2026-08-22.** `GET /chat/sessions/:id` now reports `isCommander`, derived from the session's own cwd
|
||||
by the same `isCommandersDir` the list and `/chat/live` already used — so the answer travels with the
|
||||
conversation instead of with the screen showing it.
|
||||
|
||||
The composer reads ONE value, `manager.isCommander`, true when either source says so. Both are needed and
|
||||
neither is sufficient: the SCREEN knows when Command HQ starts a commander, before a session or a transcript
|
||||
exists for any route to report on, and the SESSION knows once it does, wherever it is opened. Screen-only
|
||||
was this bug; session-only would have lost the brand-new composer.
|
||||
|
||||
Verified on a real commander: `172cdd8c` resolves `cwd=…/commanders` and `isCommander=true`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: multi-user/assertpermissiontotality-checks-the-wrong
|
||||
title: "`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13"
|
||||
title: '`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13'
|
||||
theme: multi-user
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-13
|
||||
date_source: stated
|
||||
order: 12
|
||||
@@ -17,3 +17,27 @@ a dead socket. But the boot check that exists to stop exactly this cannot see it
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
---
|
||||
|
||||
**Fixed 2026-08-22.** Half of this item was already stale and half was still exactly true, which is worth
|
||||
separating because the stale half is what would have made someone close it.
|
||||
|
||||
Stale: the eight red tests in `registry.test.ts` are green, and have been since cliamp was deleted on
|
||||
2026-08-15 — that removed the live instance of the drift along with the routes.
|
||||
|
||||
Still true: the two lists were still two lists. `assertPermissionTotality` read `Object.keys(handlers)`, the
|
||||
map of implementations, while Bun served a hand-written route table twenty lines away. The comment beside
|
||||
the check claimed neither could be a stale copy of the other, and that was simply false.
|
||||
|
||||
The fix is one declaration. `WS_ROUTES` maps path → provider, the Bun route table is GENERATED from it, and
|
||||
the boot check reads it — so a socket route cannot exist without being checked. Two smaller guards came with
|
||||
it: a route naming a provider with no handler now refuses to boot with the path in the message, and the
|
||||
provider union that was inline in `upgradeWs` is named (`UpgradableProvider`) so the route table typechecks
|
||||
against what that function can actually serve.
|
||||
|
||||
`sidecar` and `dev-proxy` are listed explicitly as served-but-not-in-WS_ROUTES, because they upgrade through
|
||||
their own handlers. Named rather than omitted, so their absence cannot read as an oversight.
|
||||
|
||||
Verified by running the check against the real prefix lists: the served set passes, and adding one unclaimed
|
||||
provider is refused with `websocket provider 'cliamp-audio' is served but no permission claims it`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: multi-user/permissions-authorize-ts-has-no-automated
|
||||
title: "`permissions/authorize.ts` has no automated tests"
|
||||
title: '`permissions/authorize.ts` has no automated tests'
|
||||
theme: multi-user
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-20
|
||||
date_source: assumed
|
||||
order: 11
|
||||
@@ -12,3 +12,32 @@ order: 11
|
||||
registry functions and the totality check; the resolver that does the owner bypass, the grant
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
---
|
||||
|
||||
**Done 2026-08-22. 27 tests, no mock and no database.**
|
||||
|
||||
The reason it had none was structural: every path through the resolver needed two database calls, and the
|
||||
only file in this repository that mocks `officerdb` leaks its stub into whatever runs after it — so the
|
||||
obvious approach would have traded one untested file for a flaky suite.
|
||||
|
||||
So the decisions were split from the fetching. `resolveEffectivePermissions(user, roleGrants)`,
|
||||
`decideApiRequest(effective, method, path)` and `decideWsProvider(effective, provider)` are pure; the async
|
||||
functions keep the caching, the fail-closed catches and the shape callers use. The tests use REAL registry
|
||||
keys throughout, deliberately — a suite built on invented permissions keeps passing after a rename, which is
|
||||
the failure an authorization table can least afford.
|
||||
|
||||
What is pinned, beyond the happy paths: the owner carries no grants to consult; core is `write`
|
||||
unconditionally; a confined grant is dropped without a Linux user, and all three confined permissions drop
|
||||
together; execution and admin grants in the table are ignored even though no CHECK stops the row existing;
|
||||
an unknown key is ignored without disturbing what is legitimately held; self-service is exact in both method
|
||||
and path; `readOnlyWrites` is relative to the permission's prefix; a socket needs no level; and an
|
||||
unauthenticated caller is refused every door.
|
||||
|
||||
Two things deliberately NOT covered, and said so in the file rather than left to be noticed: `ownerOnly`,
|
||||
because no permission declares it yet and covering it would test the fixtures rather than the registry; and
|
||||
the grant cache, whose contract is a claim about call sites rather than logic.
|
||||
|
||||
Found while writing them: `readOnlyWrites` paths are relative to the permission's api prefix, and getting
|
||||
that wrong fails as "no permission covers this path" rather than as a level error — a misleading message for
|
||||
a misleading path.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: shell/all-keyboard-shortcuts-configurable
|
||||
title: "All keyboard shortcuts configurable"
|
||||
theme: shell
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**All keyboard shortcuts configurable.** Stated 2026-08-22 as coming soon. Every binding in the app
|
||||
should be something the owner sets, not something the code decides.
|
||||
|
||||
**There is nothing central to configure today.** Nine separate `keydown` listeners are registered across
|
||||
the app — the code editor, the file browser and its grid and dialogs, the terminal, the chat launcher,
|
||||
the embeddable chat, the file viewer's editor, and Spotlight — each owning its own keys, in its own
|
||||
component, with no registry and no way to see the set at once. So the first half of this is an
|
||||
inventory, and the second is a registry the bindings are read FROM rather than written INTO the
|
||||
components.
|
||||
|
||||
Spotlight is the cautionary tale and it is already written down in `Spotlight.tsx:14`: the launcher went
|
||||
`ctrl-space` → `shift-space` → `alt-space`, three changes in a day, because each chord turned out to
|
||||
collide with something — `shift-space` types a space, which is why that version needed a focus check
|
||||
that `alt-space` does not. **A hard-coded chord costs a code change and a release every time it is
|
||||
wrong**, which is the argument for this item in one sentence.
|
||||
|
||||
Worth deciding with the config format: a binding that conflicts with another must be refused or flagged
|
||||
at the moment it is set, since the failure otherwise is silent and shows up as "that key does nothing".
|
||||
And a browser cannot claim every chord — some are the browser's or the window manager's — so the UI has
|
||||
to be able to say a binding was rejected by something above it.
|
||||
|
||||
Storage lands on `todo/settings/` — this is exactly the kind of preference that should be a portable
|
||||
file rather than a row.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: shell/ctrl-shows-every-keyboard-shortcut-in-a-modal
|
||||
title: "Ctrl+? shows every keyboard shortcut in a modal"
|
||||
theme: shell
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 4
|
||||
---
|
||||
|
||||
**Ctrl+? shows every keyboard shortcut in a modal.** Stated 2026-08-22, alongside making the bindings
|
||||
configurable. One chord, and the whole set is on screen.
|
||||
|
||||
It is the same work seen from the other end, and the order matters: **the modal is trivial once a
|
||||
registry exists and impossible to keep honest before that.** Today the bindings live in nine separate
|
||||
`keydown` listeners across the app, so a hand-written list of shortcuts would be a second copy of
|
||||
something nobody updates — a help screen that lies is worse than none. Build it as a render of the
|
||||
registry from `shell/all-keyboard-shortcuts-configurable`, never as its own list.
|
||||
|
||||
Two things to get right when it is built: the modal should show which bindings apply **here** versus
|
||||
everywhere, since most of the nine are scoped to one app or panel; and it is the natural place to
|
||||
surface a conflict or a chord the browser refused, because that is when someone is looking.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: shell/revamp-the-menus-toward-an-omarchy-like-shape
|
||||
title: "Revamp the menus — spotlight, themes — toward an Omarchy-like shape"
|
||||
theme: shell
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 5
|
||||
---
|
||||
|
||||
**Revamp the menus — spotlight, themes — toward an Omarchy-like shape.** Flagged 2026-08-22 as a plan
|
||||
the owner has and has deliberately not worked out yet: *"i have plans… i think some time soon we will
|
||||
take a more Omarchy like approach but i am too happy right now to think about that."*
|
||||
|
||||
**Recorded so it is not lost, not so someone can start it.** The shape is his and does not exist on
|
||||
paper yet — ask him before designing anything here.
|
||||
|
||||
What is already true and will be the raw material: Spotlight exists and opens on `alt-space`
|
||||
(`Spotlight.tsx`), the dock is full and cannot grow, and all 22 themes are generated from Omarchy's own
|
||||
palettes by `scripts/generate-themes.ts` — so the borrowing has already started on the colour side and
|
||||
this is the same borrowing extended to the menus. `shell/dock-effects-and-auto-hide` and the launcher
|
||||
item are both part of whatever this becomes rather than separate work.
|
||||
|
||||
Note the two keyboard items in this theme are probably prerequisites rather than neighbours: an
|
||||
Omarchy-like shell is keyboard-driven first, and that needs bindings to be configurable and discoverable
|
||||
before the menus are rebuilt around them.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: waiting-on-owner/review-the-officer-secret-store-built-last
|
||||
title: "Review officer-secret-store, built overnight and untested by the owner"
|
||||
theme: waiting-on-owner
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**Review `officer-secret-store`, built overnight and untested by the owner.** Filed 2026-08-22 at his
|
||||
request: it was implemented last night, he has not had time to look at it, and it is waiting on him
|
||||
rather than on more work.
|
||||
|
||||
**It is NOT part of the platform and must not be merged into it.** A separate project, in a separate
|
||||
repository, informed by what the platform does but deliberately its own thing:
|
||||
|
||||
- **Working tree:** `/home/pastilhas/officer-dev/officer-secret-store`
|
||||
- **Remote:** `git@gitea.officer.dev:officerdev/secret-store.git`
|
||||
- Five commits, 16 TypeScript files across `src/` and `client/`, seven documents in `docs/`, two test
|
||||
files (`access.test.ts`, `crypto.test.ts`).
|
||||
|
||||
What it claims to be, from its own README: a secret store for a tailnet, where apps fetch credentials
|
||||
over HTTP **holding no credential to do it** — the caller is identified by asking the local tailscaled
|
||||
which node is at the source address, so nothing the client sends is consulted and a client cannot lie
|
||||
about who it is. Access is a grant joining an app id to a node, and absence denies. It has **no npm
|
||||
packages at runtime**, on the argument that a store defending against dependency-chain attacks cannot
|
||||
have a dependency chain.
|
||||
|
||||
Its own status line: *"working, tested end to end on a live tailnet, not yet running anything real."*
|
||||
One commit records a live cross-machine check — `alpha` knew the app id and was refused — which is the
|
||||
right shape of test for the claim above.
|
||||
|
||||
Where to start, per the README: `docs/authentication.md`, which explains why the original design
|
||||
(authenticating with the app id alone) cannot work. That is the reasoning the whole thing turns on, so
|
||||
it is the part most worth a second pair of eyes. `docs/threat-model.md` and `docs/decisions.md` are the
|
||||
other two that carry judgement rather than description.
|
||||
|
||||
Related but distinct: the platform's own `docs/secret-store.md` is a **design, not built** — moving the
|
||||
encryption and signing keys out of `.env` into a SQLite store on this machine. Whether that design is
|
||||
now answered by this project, or stays separate, is a question for the review rather than an assumption
|
||||
either way.
|
||||
Reference in New Issue
Block a user