Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
121a77ce7f | ||
|
|
a7c9dab22c | ||
|
|
5005726994 | ||
|
|
e9f1760f27 | ||
|
|
a2102c5b46 | ||
|
|
a7b95a7bd1 | ||
|
|
9f2b703b4b | ||
|
|
9a3b5dd649 | ||
|
|
f9f4753425 | ||
|
|
8a12d5094b | ||
|
|
4adf96efa4 | ||
|
|
2ed47fa65e | ||
|
|
367006fb5d | ||
|
|
cb41a8fe16 | ||
|
|
0ef1bbc3c4 | ||
|
|
355fcaf1e5 | ||
|
|
42d9068f7b | ||
|
|
a7b61a07eb | ||
|
|
d3deca0d08 | ||
|
|
d0f83b58c9 | ||
|
|
5f803c3fb0 | ||
|
|
4efca226f6 | ||
|
|
4a6e4a773c | ||
|
|
ac25cd4860 | ||
|
|
e1f5d01b90 | ||
|
|
16da10150b |
@@ -541,6 +541,11 @@ explaining why it was safe.
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
|
||||
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
|
||||
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
|
||||
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
|
||||
`proxy-manager` ships a chat brief today)
|
||||
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
|
||||
@@ -18,45 +18,47 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
- **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
||||
- **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
|
||||
|
||||
## Themes
|
||||
|
||||
**97 open, 31 closed**, across 32 themes.
|
||||
**100 open, 31 closed**, across 33 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 3 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [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/) | 5 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 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 |
|
||||
| [SendOff](todo/sendoff/) | 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/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [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 |
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 4 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 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/) | 5 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 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 |
|
||||
| [SendOff](todo/sendoff/) | 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/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [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
|
||||
|
||||
@@ -69,11 +71,11 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
|
||||
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||
- `2026-08-25` **Sweep: AGENTS.md becomes the real instructions everywhere, CLAUDE.md a pointer beside it** — [docs-conventions/sweep-agents-md-primary-claude-md-pointer](todo/docs-conventions/sweep-agents-md-primary-claude-md-pointer.md)
|
||||
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
|
||||
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
|
||||
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
|
||||
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
|
||||
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
|
||||
- `2026-08-23` **A worker cannot verify that the owner delegated to a manager, so it has to ask every time** — [agent-coordination/a-peer-cannot-verify-a-delegation](todo/agent-coordination/a-peer-cannot-verify-a-delegation.md)
|
||||
- `2026-08-23` **An agent cannot learn its own transcript id, so it cannot sign its own work** — [agent-coordination/an-agent-cannot-learn-its-own-session-id](todo/agent-coordination/an-agent-cannot-learn-its-own-session-id.md)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 2026.08.25.01 — report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`);
|
||||
neither is a failure. Every target ended at `007cc9a1` = `deploy/2026.08.25.01` exactly.
|
||||
|
||||
The schema change was verified **by query on each host's own database**, not by exit code. All five
|
||||
returned both columns.
|
||||
|
||||
First release under a new watcher. The claim was taken by the watcher before this agent was spawned;
|
||||
this agent recorded `done` after the rollout.
|
||||
|
||||
## The schema change
|
||||
|
||||
Two nullable columns on `api_keys`. The plan's query, run per host:
|
||||
|
||||
```sql
|
||||
select column_name from information_schema.columns
|
||||
where table_name = 'api_keys' and column_name in ('permissions','excluded_kinds');
|
||||
```
|
||||
|
||||
| Host | `permissions` | `excluded_kinds` | Result |
|
||||
| ---------------------------- | ------------- | ---------------- | ---------------- |
|
||||
| `alpha` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `fofos` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `production-pertento-bridge` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `deadpool-rezio-bridge` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
| `macbookpro` | ARRAY, null | ARRAY, null | 2 rows — pass |
|
||||
|
||||
No half-applied push anywhere — no host returned `permissions` alone.
|
||||
|
||||
`bun db:push` exited 0 on all five and printed `Changes applied`. That was treated as not-evidence, per
|
||||
the plan; the table above is the evidence. `postdb:push` seeders ran clean everywhere (`seed:roles`
|
||||
0 seeded / 4 already present on all five).
|
||||
|
||||
**No direct SQL was run on any host.** None was authorised and none was needed.
|
||||
**No `bun install` was run on any host**, per the plan — `bun.lock` is untouched by this release.
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — success
|
||||
|
||||
- HEAD `23657210` (`deploy/2026.08.24.02-29-g23657210`) → `007cc9a1` (`deploy/2026.08.25.01`)
|
||||
- Tree clean of tracked changes. No untracked files.
|
||||
- Path `/home/pastilhas/officerdev/platform`, taken from PM2 `pm_cwd` — **not** the stale second
|
||||
checkout at `~/dockers/officer.dev/platform`.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — **14/14 updated in 57s**, exit 0. Fourteen plugin sidecars restarted
|
||||
themselves during this step (atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, minesweeper,
|
||||
music, offscale, photos, soulseek, transcribe, transmission, vault).
|
||||
- `pm2 restart officer` — `restart_time` 2 → 3, `online`.
|
||||
- Health after: **21/21 processes online**, none errored.
|
||||
|
||||
### `fofos` — success
|
||||
|
||||
- HEAD `e0d80fd3` (`deploy/2026.08.24.02`) → `007cc9a1`
|
||||
- Tree clean of **tracked** changes. One untracked file present: `scripts/chk.ts`. Noted, not touched,
|
||||
not a reason to skip — a fast-forward could not have affected it.
|
||||
- Remote is HTTPS here, as documented. Pulled in place.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — 1/1 (gitea), exit 0; its sidecar restarted itself.
|
||||
- `pm2 restart officer` — `restart_time` 28 → 29, `online`.
|
||||
- Health after: **10/10 processes online**, none errored.
|
||||
- The August root-ownership problem did not recur: git and the plugin step both ran clean.
|
||||
|
||||
### `production-pertento-bridge` — success
|
||||
|
||||
- HEAD `e0d80fd3` (`deploy/2026.08.24.02`) → `007cc9a1`
|
||||
- Tree clean, no untracked files.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — 1/1 (gitea), exit 0.
|
||||
- `pm2 restart officer` — `restart_time` 7 → 8, `online`.
|
||||
- Health after: **15/15 processes online**, none errored.
|
||||
- Cosmetic only: the interactive shell prints an nvm warning about a `globalconfig`/`prefix` in
|
||||
`~/.npmrc`. It did not affect any step. Not fixed — outside a rollout's scope.
|
||||
|
||||
### `deadpool-rezio-bridge` — success
|
||||
|
||||
- Logged in as **`br`**, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||
- HEAD `e0d80fd3` (`deploy/2026.08.24.02`) → `007cc9a1`
|
||||
- Tree clean, no untracked files.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — 2/2 (email, offscale), exit 0; both sidecars restarted themselves.
|
||||
- `pm2 restart officer` — `restart_time` 13 → 14, `online`.
|
||||
- Health after: **9/9 processes online**, none errored.
|
||||
|
||||
### `macbookpro` — success (it was awake)
|
||||
|
||||
- Awake and reachable, so it was a target rather than a skip.
|
||||
- HEAD `8e2670ff` (`deploy/2026.08.24.02-96-g8e2670ff`, one commit short of the tag) → `007cc9a1`
|
||||
- Tree clean, no untracked files.
|
||||
- `/opt/homebrew/bin` **appended** to PATH for the `bun` commands; `pm2` run as a separate command
|
||||
through `$SHELL -ic`, per `docs/servers.md`. Both worked.
|
||||
- `bun db:push` exit 0 → verified by query, 2 rows.
|
||||
- `bun run plugins:update` — `no plugins installed — nothing to update`, exit 0. Nothing restarted here
|
||||
by that step.
|
||||
- `pm2 restart officer` — `restart_time` 15 → 16, `online`.
|
||||
- Health after: **6/6 processes online**, none errored.
|
||||
|
||||
## Skipped
|
||||
|
||||
### `officer-dev` — skipped, and deliberately
|
||||
|
||||
The host this rollout ran on. Not pulled, not restarted, nothing touched. Restarting `officer` here
|
||||
would have ended the rollout mid-flight.
|
||||
|
||||
Its checkout is **ahead** of the tag: HEAD `e1f5d01b`, two commits past `007cc9a1`, with uncommitted
|
||||
work belonging to another session (`src/servers/hono.ts`, `src/workspaces/hooks/src/useClient.ts`, and
|
||||
an untracked `src/servers/hono-error-shape.test.ts`). It therefore already holds this release's code and
|
||||
more. **The owner restarts it deliberately when they are happy with the rest** — that is the one
|
||||
outstanding action from this deploy.
|
||||
|
||||
### `edge-pertento-bridge` — skipped by the plan. NOT a failure.
|
||||
|
||||
Excluded by name in `deploy/2026.08.25.01.md`, which is the authority here. It has no `roles` table
|
||||
while both its accounts hold roles, so a single `db:push` would create the foreign key against an empty
|
||||
`roles` and fail — possibly while still exiting 0. It also missed two releases carrying one-off steps
|
||||
that a fast-forward does not replay.
|
||||
|
||||
Not contacted at all. No time spent on it. Bringing it forward is a watched, by-hand operation the owner
|
||||
is doing separately.
|
||||
|
||||
## What did not get done
|
||||
|
||||
- `officer-dev` was not restarted — by design, see above. It is the only host not running a
|
||||
freshly-started `officer` process, though it is on newer code than the tag.
|
||||
- No verification of the release's *behaviour* was attempted: whether a commander asks its first
|
||||
question in the owner's language, and whether a Member's phone can now register for notifications,
|
||||
are the plan's "what to check" items and they need a human with a browser. Hard-refresh before
|
||||
judging either — a stale bundle looks exactly like a failure.
|
||||
- Documentation was not checked or edited. `bun run docs:check` is the release-cutter's step, not this
|
||||
agent's.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 2026.08.26.01
|
||||
|
||||
A plugin can own a chat — its own directory, its own brief, its own history — and a dead commander watcher
|
||||
wakes itself up.
|
||||
|
||||
22 commits. **No schema change** and **no dependency change**: nothing under `src/databases/**/schema.ts`
|
||||
moved and `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
`bun db:push` stays a step because it is cheap and proves the database agrees with the code, but **it must
|
||||
report no changes to the platform's own tables.** A host with the `proxy-manager` plugin installed will see
|
||||
that plugin's three tables created — that is the plugin, not this release, and it is expected.
|
||||
|
||||
## Why this one matters more than its size suggests
|
||||
|
||||
**`officerdev` gained an export that a shipped plugin already imports.** The `proxy-manager` plugin in the
|
||||
catalogue imports `ChatHeader` from `officerdev`, which exists only from `2ed47fa6` — after the last
|
||||
release. On a host running `2026.08.25.01` the plugin installs, its frontend rebuild fails with
|
||||
`No matching export … for import "ChatHeader"`, and the SPA silently keeps its previous bundle: the plugin
|
||||
appears installed and its route redirects to home.
|
||||
|
||||
That is exactly what happened on `fofos` tonight. **So this release is what makes that plugin installable
|
||||
anywhere**, and any host that has already installed it is in the broken state until this lands.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **A plugin can declare a chat context.** `ChatPanelWrapper` takes `chatContext`, so a plugin's chats run
|
||||
in `$DATA_PATH/<email>/plugins/<appName>/chats` — resolved server-side, never a path from the client.
|
||||
`plugins` joins `commanders` as a sibling of `home` that a member OWNS, or their turn dies on its own
|
||||
working directory (the `general_chat_sessions` failure, third time).
|
||||
- **A plugin briefs its chats with a file.** `plugins/<name>/chat/CLAUDE.md` is copied into that directory
|
||||
on every chat that starts there, replacing per-turn prompt prefixes.
|
||||
- **A plugin can list its own chats** — `/chat/sessions?context=plugin&contextId=<appName>`.
|
||||
- **Sonnet is the default when nobody chose.** Every surface without a picker was running Opus, because the
|
||||
fallback was the bare harness and bare means "whatever the CLI picks". A stored bare harness is now read
|
||||
as unset, which is what made the change appear to do nothing on machines that had one.
|
||||
- **A chat's API key follows its holder, minus `admin`** — was `notify` only. Note this takes effect in
|
||||
`officer-claude-code`, NOT `officer`.
|
||||
- **A locked screen persists sizes, not structure.** A plugin or core screen changing its layout now
|
||||
reaches people who have already opened it, and the `appTypes` repair can no longer be written back as
|
||||
though the user had chosen it.
|
||||
- **A dead commander watcher wakes its commander**, and the guard that was silently disabling that is
|
||||
fixed — it asked about background tasks when it meant "is the agent mid-turn", and a commander's watcher
|
||||
is itself a background task.
|
||||
- Theme browser and Spotlight keybindings; two `todo/` entries; the CLAUDE.md touchpoints audit.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.26.01`
|
||||
2. `bun db:push` — expect **no changes** to platform tables. If it wants to alter one, stop and report it.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. Asleep
|
||||
is a skip with a note, not a failure.
|
||||
|
||||
## `officer-claude-code` is NOT restarted by this, and that is deliberate
|
||||
|
||||
One change in here — the widened chat credential — lives in the agent sidecar rather than in `officer`, so
|
||||
step 4 does not activate it. **Do not restart `officer-claude-code` as part of the rollout.** It ends every
|
||||
live agent session on that host, including any agent running the rollout itself, and it is the owner's call
|
||||
when to take that. Say in the report that it is pending; do not do it.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It already has this code.
|
||||
- **`edge-pertento-bridge`** — skipped, unchanged from last release. It is 300+ commits behind with **no
|
||||
`roles` table**, and a plain `db:push` there would create a foreign key onto an empty table. Its catch-up
|
||||
is a by-hand job the owner is doing separately. Do not contact it.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**On `fofos` specifically**, because it is the reason for the release: after step 4, `/proxy-manager` should
|
||||
render rather than redirecting to home, and the log should say `[plugins] frontend built` with no
|
||||
`rebuild failed` line after it. If it still redirects, the bundle is stale — hard refresh before concluding
|
||||
anything.
|
||||
|
||||
Everywhere else: a chat should default to **sonnet** rather than opus or fable in the model picker. That is
|
||||
the most visible change and the easiest to confirm.
|
||||
|
||||
No direct SQL is needed on any host, and none is authorised.
|
||||
@@ -0,0 +1,170 @@
|
||||
# 2026.08.26.01 — report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`);
|
||||
neither is a failure. Every target ended at `e9f1760f` = `deploy/2026.08.26.01` exactly.
|
||||
|
||||
**No schema change in this release**, and that was verified rather than assumed — the diff between
|
||||
`deploy/2026.08.25.01` and this tag touches no `schema.ts` and leaves `bun.lock` untouched. **No
|
||||
`bun install` was run anywhere.** **No direct SQL was run on any host**; none was authorised.
|
||||
|
||||
First release under a new watcher, and the first the loop has ever executed. The claim was taken by the
|
||||
watcher before this agent was spawned; this agent recorded `done` after the rollout.
|
||||
|
||||
## One finding the owner should see: `db:push` is not a no-op on any host
|
||||
|
||||
`bun db:push` exited 0 and printed `Changes applied` on all five. Per doctrine that is not evidence, so
|
||||
each host was re-run with `--verbose` to see what push actually wanted. **Every host wants the same
|
||||
statement, every single run:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||
```
|
||||
|
||||
`alpha` additionally wants a drop-and-re-add of `pk_music_now_playing` (the music plugin's composite
|
||||
primary key).
|
||||
|
||||
**This release did not cause it and it is not damage.** The reasoning is deductive rather than a guess:
|
||||
this release changes zero `schema.ts` files, so drizzle's desired state is byte-identical before and
|
||||
after the merge — anything push wants is pre-existing drift. It was verified by query that the database
|
||||
is already in the state the schema code asks for:
|
||||
|
||||
| Host | `command_hq_sessions.reference_paths` default | `pk_music_now_playing` | `roles` |
|
||||
| ---------------------------- | --------------------------------------------- | ---------------------- | ------- |
|
||||
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
|
||||
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
|
||||
So drizzle cannot match `'{}'` against the stored `'{}'::text[]` and re-emits the ALTER forever. It is
|
||||
the array-default cousin of the named-composite-constraint mis-diff already documented in
|
||||
`src/databases/CLAUDE.md` → "Composite keys", and `pk_music_now_playing` on `alpha` is that exact
|
||||
documented case.
|
||||
|
||||
**Why it matters even though it is harmless today:** it permanently spends the signal. The plan said
|
||||
"expect no changes to platform tables — if it wants to alter one, stop and report it", and that stop
|
||||
condition can never fire cleanly while a platform table is on the standing-diff list. A future release
|
||||
that really does want an unexpected ALTER will look exactly like tonight. Worth fixing at the schema
|
||||
code, not on the hosts.
|
||||
|
||||
The rollout was not stopped for it. Non-destructive, pre-existing, and the databases are in the state
|
||||
the code asks for.
|
||||
|
||||
`postdb:push` seeders ran clean everywhere: `seed:roles` 0 seeded / 4 already present on all five,
|
||||
`backfill:session-models` 0 seeded on all five.
|
||||
|
||||
## The reason for this release — verified on `fofos`
|
||||
|
||||
The plan exists because `proxy-manager` imports `ChatHeader`, which did not exist before `2ed47fa6`, so
|
||||
the plugin's frontend rebuild failed and the SPA silently kept its old bundle.
|
||||
|
||||
Verified after the restart, from the logs and the build output rather than by eye:
|
||||
|
||||
- `[plugins] frontend built in 7693ms (3 outputs)` at out-log line **1777 of 1808** — the current boot.
|
||||
- The `No matching export … for import "ChatHeader"` line is in the error log **above** the last
|
||||
`gen-index` / `server.tsx` boot markers, i.e. it belongs to the **previous** boot. No `rebuild failed`
|
||||
after the successful build.
|
||||
- `build/` was rewritten at 04:46:04, seconds after the restart, and `chunk-b7epbzfm.js` contains
|
||||
`proxy-manager` 41 times.
|
||||
|
||||
`ChatHeader` itself greps to 0 in the bundle — expected, production bundling renames it. The absence of
|
||||
the build error is the signal, not the presence of the symbol.
|
||||
|
||||
**The owner still needs a hard refresh (Ctrl/Cmd-Shift-R) before judging `/proxy-manager` in a browser**
|
||||
that is holding the old bundle. Not checked from a browser by this agent.
|
||||
|
||||
## Per host
|
||||
|
||||
Steps in plan order everywhere: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
|
||||
`pm2 restart officer`.
|
||||
|
||||
### `alpha` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Tree clean, no untracked files.
|
||||
- `db:push` exit 0. Standing diff as above; database verified correct by query.
|
||||
- `plugins:update`: **14/14 updated in 59s**, exit 0. This bounced fourteen sidecars on its own, before
|
||||
any restart the plan asked for — the report line below understates what actually moved.
|
||||
- `pm2 restart officer`: restart_time 3 → 4, `online`, uptime 14s at check.
|
||||
- All **21** PM2 entries `online`. `officer-claude-code` untouched (uptime 28h).
|
||||
|
||||
### `fofos` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Tracked-clean. **One untracked file: `scripts/chk.ts`** — noted, not a
|
||||
reason to skip, and not touched.
|
||||
- `db:push` exit 0; `proxy_manager_connection`, `proxy_manager_dns_credentials`, `proxy_manager_domains`
|
||||
all present, as the plan predicted for a proxy-manager host.
|
||||
- `plugins:update`: 3/3 updated in 28s, exit 0 (browser, gitea, proxy-manager).
|
||||
- `pm2 restart officer`: restart_time 29 → 30, `online`, uptime 26s at check.
|
||||
- All **11** PM2 entries `online`. Frontend rebuild verified — see the section above.
|
||||
- Note: this host's `.env` names the database `OFFICERDEV_POSTGRES_URL`, not `POSTGRES_URL` as on the
|
||||
others. Cost one failed probe. Worth knowing for the next orchestrator that reads `.env` directly.
|
||||
|
||||
### `production-pertento-bridge` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query. 39 tables in `public`.
|
||||
- `plugins:update`: 1/1 updated in 4s, exit 0 (gitea).
|
||||
- `pm2 restart officer`: restart_time 8 → 9, `online`, uptime 22s at check.
|
||||
- All **15** PM2 entries `online`. `officer-claude-code` untouched (uptime 76h).
|
||||
- Every command on this host prints an nvm/`.npmrc` `globalconfig` warning. Pre-existing, cosmetic,
|
||||
nothing failed because of it — but it is noise an orchestrator has to filter, and it will eventually
|
||||
hide something.
|
||||
|
||||
### `deadpool-rezio-bridge` — success
|
||||
|
||||
- Login `br`, checkout `/home/br/officerdev/platform`, taken from PM2's `pm_cwd`.
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query.
|
||||
- `plugins:update`: 2/2 updated in 8s, exit 0 (email, offscale).
|
||||
- `pm2 restart officer`: restart_time 14 → 15, `online`, uptime 22s at check.
|
||||
- All **9** PM2 entries `online`.
|
||||
|
||||
### `macbookpro` — success (it was awake)
|
||||
|
||||
- Checkout `/Users/pastilhas/projects/officer-suite/platform`. `PATH` **appended** with
|
||||
`/opt/homebrew/bin` for the `bun` commands; `pm2` run as a separate command, per `docs/servers.md`.
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query.
|
||||
- `plugins:update`: **no plugins installed — nothing to update**, exit 0.
|
||||
- `pm2 restart officer`: restart_time 16 → 17, `online`, uptime 27s at check.
|
||||
- All **6** PM2 entries `online`.
|
||||
|
||||
## Skipped
|
||||
|
||||
### `officer-dev` — skipped, by plan and by doctrine
|
||||
|
||||
Not pulled, nothing restarted. This agent runs inside `officer-claude-code` on this machine; restarting
|
||||
`officer` here ends the rollout. It already carries this code — its HEAD was `e9f1760f` before the
|
||||
rollout began.
|
||||
|
||||
**Its working tree holds another session's uncommitted work** — modified `src/servers/hono.ts` and
|
||||
`src/workspaces/hooks/src/useClient.ts`, plus an untracked `src/servers/hono-error-shape.test.ts`. Left
|
||||
alone. The report commit below names only its own path.
|
||||
|
||||
The owner restarts this host deliberately, as the last step, once happy with the rest.
|
||||
|
||||
### `edge-pertento-bridge` — skipped, by plan. Not contacted.
|
||||
|
||||
**And `docs/servers.md` disagrees with the plan about this host.** The doc's "Deliberately skipped"
|
||||
section says "None today" and lists `edge-pertento-bridge` in the platforms table as "a deploy target
|
||||
again". The plan says skip it: 300+ commits behind, **no `roles` table**, and a plain `db:push` there
|
||||
would create a foreign key onto an empty table. Its catch-up is a by-hand job the owner is doing
|
||||
separately.
|
||||
|
||||
The plan won — it is authoritative for a release, and it is the more recent statement. Nothing was sent
|
||||
to that host, not even a reachability probe. **`docs/servers.md` should be corrected**, because the next
|
||||
orchestrator that reads the doc rather than the plan will `db:push` a host that cannot take one. Not
|
||||
fixed here: a rollout does not edit documentation.
|
||||
|
||||
## What was NOT done
|
||||
|
||||
- **`officer-claude-code` was not restarted on any host**, per the plan. The widened chat credential
|
||||
(an API key follows its holder, minus `admin`) lives in that sidecar, so **it is not active anywhere
|
||||
yet**. It is the owner's call when to take that — it ends every live agent session on the host,
|
||||
including this one. **Pending on all five targets plus `officer-dev`.**
|
||||
- **`officer-dev` was not deployed.** Skipped whole, per above.
|
||||
- **The sonnet default was not confirmed from a UI on any host.** The plan names it as the easiest
|
||||
visible check; this agent has no browser. It shipped in the code that is now at `e9f1760f` on all
|
||||
five, unverified at the surface.
|
||||
- **`/proxy-manager` was not opened in a browser.** Verified from the build and the logs instead.
|
||||
- `bun run docs:check` was not run — not a rollout step, and this agent does not fix documentation.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026.08.26.02
|
||||
|
||||
**One fix, and it is a crash.** A screen whose layout names a panel it does not own now renders instead of
|
||||
failing with React #185, "maximum update depth exceeded".
|
||||
|
||||
**No schema change. No dependency change. Do not run `bun install`.** `bun db:push` stays a step because it
|
||||
is cheap and proves the database agrees with the code, but it must report no changes.
|
||||
|
||||
## What broke, and where it shows
|
||||
|
||||
`useAuthoredLayout` shipped in `2026.08.26.01`: a locked screen persists its SIZES and takes its STRUCTURE
|
||||
from source on every render. That is still right, and it is what lets a plugin change its layout and reach
|
||||
people who have already opened it.
|
||||
|
||||
What it did not account for is `WorkspaceView`'s `appTypes` guard, which repairs an unrecognised `appType`
|
||||
and **writes the repair back**. That was self-limiting while structure was persisted — the repair stuck and
|
||||
the next render agreed with it. With structure coming from source it can never stick, so it is recomputed,
|
||||
rewritten, discarded and recomputed every render until React gives up.
|
||||
|
||||
**The `browser` plugin's layout names `officerdev/chat`**, which is not one of its own panel keys. So
|
||||
`/browser` fails to render on any host with that plugin installed. That is every host running
|
||||
`2026.08.26.01` with it — which is the fleet.
|
||||
|
||||
The fix drops a write whose result equals the current value. Not an optimisation: it is what terminates the
|
||||
loop, and persisting an identical tree was pointless anyway.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.26.02`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
**`fofos` is already at the fix** — it was fast-forwarded by hand at 05:22 to `a7c9dab2` while the owner was
|
||||
working on it. Step 1 will fast-forward it to this tag anyway, which only adds the version bump and this
|
||||
plan. Nothing about that is a problem; note it rather than treating it as drift.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. Asleep is
|
||||
a skip with a note.
|
||||
|
||||
**Do not restart `officer-claude-code`.** Unchanged from the last release, same reason: it ends every live
|
||||
agent session on the host, and it is the owner's call. Nothing in this release needs it.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It already has this code.
|
||||
- **`edge-pertento-bridge`** — skipped. Still 300+ commits behind with no `roles` table; its catch-up is a
|
||||
by-hand job. Do not contact it.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
|
||||
## What to check
|
||||
|
||||
**Open `/browser`.** It should render rather than showing "This screen failed to render". That is the whole
|
||||
release, and it is one click to confirm. Hard refresh first — a stale bundle looks identical to the bug.
|
||||
|
||||
No direct SQL is needed anywhere, and none is authorised.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Every place the platform writes, appends to, or reads `CLAUDE.md` — an audit
|
||||
|
||||
Written 2026-08-25, after a request to inventory the whole mechanism before changing any part of it.
|
||||
The scope was `platform/src`, `platform/scripts`, and every plugin currently checked out under
|
||||
`platform/plugins/`. Related design docs: [`commander-directory.md`](./commander-directory.md) (why a
|
||||
commander's `CLAUDE.md` exists at all), [`plugin-capabilities.md`](./plugin-capabilities.md) (the
|
||||
read-in-place rule this mechanism is the one deliberate exception to).
|
||||
|
||||
## The shape of it
|
||||
|
||||
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files
|
||||
that merely *mention* `CLAUDE.md` in a comment suggests.
|
||||
|
||||
| # | Site | Verb | Cardinality | Trigger |
|
||||
|---|------|------|-------------|---------|
|
||||
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
|
||||
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start |
|
||||
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
|
||||
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
|
||||
|
||||
Two things fell out of tracing this that are worth stating up front, because they explain why the
|
||||
mechanism looks the way it does rather than something simpler:
|
||||
|
||||
- **Nothing here is a per-turn cost.** All three writers touch disk once (creation, chat-start,
|
||||
migration) rather than on every message. The alternative — a `promptPrefix` riding every turn — is
|
||||
exactly what this replaced, twice (once for commanders, once for plugin chats), and the reason given
|
||||
both times is the same: paid forever, invisible as a design artefact, and visible in the worst place
|
||||
(a three-word message with a paragraph of preamble silently attached).
|
||||
- **Two mechanisms that look similar are deliberately NOT this.** The owner's affordances/skills
|
||||
preamble and a commander's old per-turn block go through the Claude Code SDK's
|
||||
`systemPrompt: { preset, append }` option — in memory, per session, never touching disk. See
|
||||
"Explicitly not written" below for why that line was drawn where it was.
|
||||
|
||||
## The three writers
|
||||
|
||||
### 1. Commander creation — the generator
|
||||
|
||||
`src/servers/api/command-hq/directory.ts:653`, `createCommanderDirectory()`.
|
||||
|
||||
Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the
|
||||
row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering
|
||||
is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a
|
||||
race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first
|
||||
and the directory has to exist before a turn can reach it.
|
||||
|
||||
```ts
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
commanderClaudeMd({
|
||||
name: params.name,
|
||||
referencePath: params.referencePath,
|
||||
loopPidPath: loopPidPath(email, dirName),
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
credentialPath,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
`commanderClaudeMd()` (same file, line 173) is the template — the commander's name, the reference path
|
||||
it was pointed at, where its loop pid lives, where its spawned-process record lives, and where its
|
||||
platform credential is (or an explicit statement that minting one failed, so the commander is told
|
||||
rather than left to discover the gap itself — the comment at line 662 is explicit that this ordering,
|
||||
credential before `CLAUDE.md`, exists so the file can describe the credential truthfully).
|
||||
|
||||
Plain `writeFileSync`, not atomic — defensible because it runs once, at creation, with nothing else able
|
||||
to be reading the file yet.
|
||||
|
||||
**What happens after creation is the interesting part: nothing does, from the platform's side.** The
|
||||
file is described everywhere as "a living document its commander has been extending" — the commander
|
||||
itself edits it during its own life, the same file a human would open in the file browser. The platform
|
||||
never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with
|
||||
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
|
||||
|
||||
### 2. Plugin chat briefs — the copier
|
||||
|
||||
`src/servers/api/chat/websocket.ts:237`, `installPluginChatBrief()`.
|
||||
|
||||
```ts
|
||||
async function installPluginChatBrief(appName: string, dir: string): Promise<void> {
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled);
|
||||
if (!state) return;
|
||||
|
||||
const source = join(state.plugin.dir, 'chat', 'CLAUDE.md');
|
||||
if (!existsSync(source)) return;
|
||||
|
||||
const target = join(dir, 'CLAUDE.md');
|
||||
const next = await Bun.file(source).text();
|
||||
if (existsSync(target) && (await Bun.file(target).text()) === next) return;
|
||||
await writeAtomic(target, next);
|
||||
} catch (err) {
|
||||
console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a
|
||||
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
|
||||
for the chat directory itself, so the two writers (this one and the commander generator) can never
|
||||
target the same directory: a commander's cwd is `$DATA_PATH/<email>/commanders/<dirName>/`, a plugin
|
||||
chat's is `$DATA_PATH/<email>/plugins/<appName>/chats/`.
|
||||
|
||||
The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships in their own repository,
|
||||
read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs
|
||||
no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on
|
||||
an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called
|
||||
once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
|
||||
|
||||
Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the
|
||||
per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A
|
||||
write failure logs and continues rather than blocking the chat from starting.
|
||||
|
||||
**Of the 19 plugins checked out on this machine today, exactly one ships a brief: `proxy-manager`.**
|
||||
See "Plugins, one by one" below.
|
||||
|
||||
### 3. The credential backfill — the appender
|
||||
|
||||
`scripts/backfill-commander-credentials.ts`.
|
||||
|
||||
Not part of the runtime — a manual migration, re-run by hand after a deploy that changes what a
|
||||
commander's `CLAUDE.md` should say about itself (`bun run --env-file=.env scripts/backfill-commander-credentials.ts`
|
||||
on each host). Exists because a commander predating some capability (first: platform credentials; later:
|
||||
the notify route) has a `CLAUDE.md` that never mentions it, and deleting-and-recreating the directory to
|
||||
fix that would destroy the one thing a commander actually is — a long-running, self-extended
|
||||
conversation about a job.
|
||||
|
||||
```ts
|
||||
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
||||
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
||||
if (existing.includes(marker)) return false;
|
||||
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, claudeMd);
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
Idempotent by construction: each section carries a marker string (`officer.env` for the credential
|
||||
section, `/api/notify/_officer/notify` for the notify section) and is only appended when that marker is
|
||||
absent — so re-running the script, or running it against a `CLAUDE.md` a human hand-edited to already
|
||||
describe the same thing, does not duplicate anything. Two sections are currently backfilled this way:
|
||||
the credential path (added when `credential.ts` first shipped) and the notify route (added later,
|
||||
separately, because "arrived later" — commanders that most need a credential already have one by the
|
||||
time this runs).
|
||||
|
||||
This is the only writer of the three that **mutates an existing file** rather than producing a
|
||||
first-time one. Everything else in the mechanism either generates once (item 1) or overwrites wholesale
|
||||
on a genuine content diff (item 2) — appending-if-missing is specific to this being a repair pass over
|
||||
files a human may have already been editing by hand.
|
||||
|
||||
## The one reader
|
||||
|
||||
`scripts/docs-check.ts`, lines 92 and 108. Reads `platform/CLAUDE.md` and `officer-dev/CLAUDE.md` — not
|
||||
a plugin's, not a commander's — to check a documented numeric claim (permission-registry entry count,
|
||||
PM2 process count) against a value derived at run time, and fails the check on drift. This is `bun
|
||||
docs:check`, required before every release. It never writes.
|
||||
|
||||
Included here for completeness since the request was "writing OR touching" — this is the only touch
|
||||
that is not a write.
|
||||
|
||||
## Explicitly not written — and why, so the mechanism isn't mistaken for having a gap
|
||||
|
||||
Three places came up in the trace that look like they should write a `CLAUDE.md` and deliberately don't.
|
||||
Worth recording, because each one used to work differently and the comments at each site exist
|
||||
specifically to stop a future change from "fixing" this back in.
|
||||
|
||||
- **The owner's and every session's affordances/skills preamble** —
|
||||
`src/servers/sidecar/claude/claude-manager.ts:779-798`. Passed as
|
||||
`systemPrompt: { type: 'preset', preset: 'claude_code', append: preamble }` to the Claude Code SDK's
|
||||
`query()` call, in memory, every session, never to a file. The comment there gives the reason this
|
||||
differs from the commander/plugin mechanism above: this is "the one place every session goes through,
|
||||
owner and member alike" — a file can't cover that, because `user-instance.ts` (next point) refuses to
|
||||
write one for the owner at all, and a member's isolated home is a narrower audience than "every
|
||||
session." This is also where a commander's own preamble used to live, as a per-turn block
|
||||
(`commander-block.ts`, deleted 2026-08-24) — it could never resolve on a commander's first turn, since
|
||||
the block was built once per session and the first turn has no transcript id yet. Moving that
|
||||
information into a file the directory-creation step writes before any turn spawns (item 1 above) is
|
||||
what fixed it.
|
||||
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs
|
||||
un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land
|
||||
*inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
|
||||
reads. Writing one there would silently mix generated content into a document that is supposed to be
|
||||
entirely the owner's.
|
||||
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that
|
||||
the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
|
||||
`settings.json` describing a user's Docker container, as part of an architecture that no longer exists.
|
||||
What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
|
||||
generated inside either.
|
||||
|
||||
## Plugins, one by one
|
||||
|
||||
`platform/plugins/CLAUDE.md` states the ground rule: **plugins are not part of this repository.** Every
|
||||
`plugins/<name>/` directory is gitignored here and exists on disk only because `git clone` put it there
|
||||
when it was installed on this machine — `/plugins/*/` in `.gitignore`. So "plugins, one by one" below
|
||||
means the 19 currently checked out here, not a catalogue.
|
||||
|
||||
The only convention a plugin can participate in is the one item 2 (above) reads: a `chat/CLAUDE.md` file
|
||||
at the top of the plugin's own repository, picked up automatically the moment that plugin's chat panel
|
||||
starts a conversation. There is no manifest field for this and nothing to register — the platform simply
|
||||
checks whether the file exists at that fixed path within the plugin's directory on every chat start.
|
||||
|
||||
| Plugin | `chat/CLAUDE.md` | Notes |
|
||||
|---|---|---|
|
||||
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
|
||||
| `atelier` | no | |
|
||||
| `bitcoin-wallet` | no | |
|
||||
| `browser` | no | |
|
||||
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
|
||||
| `email` | no | |
|
||||
| `example` | no | |
|
||||
| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
|
||||
| `invoices` | no | |
|
||||
| `iris` | no | |
|
||||
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
|
||||
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
|
||||
| `minesweeper` | no | |
|
||||
| `music` | no | |
|
||||
| `offscale` | no | |
|
||||
| `photos` | no | |
|
||||
| `soulseek` | no | |
|
||||
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
|
||||
| `transmission` | no | |
|
||||
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
|
||||
|
||||
**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that
|
||||
mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code
|
||||
follows (frozen lockfiles, composite keys, script-task self-containment) — not a filesystem touch. The
|
||||
one exception, `proxy-manager`, participates in item 2 purely by shipping a static file at a path the
|
||||
platform already knows to look for; it contains no code of its own that touches `CLAUDE.md`.
|
||||
|
||||
## What this means for a future change
|
||||
|
||||
Anyone changing this mechanism has exactly three call sites to reconcile, plus the two "explicitly not
|
||||
written" boundaries above that a change should not cross without a deliberate decision to do so:
|
||||
|
||||
- Change what a **new** commander is told → edit `commanderClaudeMd()` in `directory.ts`. Existing
|
||||
commanders will not retroactively pick up the change; that is what the backfill script pattern (item 3)
|
||||
is for, and a new field needs its own marker and its own `appendIfMissing` call, following the same
|
||||
script.
|
||||
- Change what a **plugin chat** is told → that lives entirely in the plugin's own `chat/CLAUDE.md`; the
|
||||
platform's role is only the copy-if-changed mechanics in `installPluginChatBrief`, which no plugin
|
||||
author needs to touch.
|
||||
- Add the same brief mechanism to a **new surface** (not commander, not plugin chat) → follow the pattern
|
||||
in `resolveChatCwd()`: a directory the surface owns, `mkdirSync` before the write, and a decision about
|
||||
whether the content is generate-once (commander) or copy-and-diff (plugin) — the two existing writers
|
||||
chose differently for reasons specific to who edits the file afterward, and a third surface should make
|
||||
that choice deliberately rather than default to whichever is closer in the file.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.25.01",
|
||||
"version": "2026.08.26.02",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Star } from 'lucide-react';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||
@@ -24,10 +24,10 @@ const {
|
||||
*
|
||||
* ── The overlay ──
|
||||
*
|
||||
* `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.
|
||||
* `ctrl+shift+↑/↓` 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 with no
|
||||
* backdrop rather than a modal dialog: you are meant to watch the application change while you browse,
|
||||
* so nothing of it should be covered.
|
||||
*
|
||||
* Ported from the owner's `vimazing`, keys and all.
|
||||
*/
|
||||
@@ -35,12 +35,10 @@ export const ThemeSwitcher = () => {
|
||||
const { theme, themeId, setTheme, themes } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
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);
|
||||
|
||||
/*
|
||||
One ordering, used by both lists.
|
||||
The DROPDOWN's ordering.
|
||||
|
||||
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
|
||||
@@ -57,6 +55,36 @@ export const ThemeSwitcher = () => {
|
||||
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
|
||||
/*
|
||||
The OVERLAY's ordering: the same rotation, favourites IN PLACE.
|
||||
|
||||
The dropdown lifts favourites to a top block, and the overlay used to as well — which made keyboard
|
||||
browsing hop into the block and back out, because the rotation and the display disagreed about what
|
||||
"next" meant. In the overlay you are browsing, not picking from a menu, so it shows ONE list in
|
||||
rotation order and the favourites filter (`tab`, or the star button) is how the shortlist is reached.
|
||||
*/
|
||||
const overlayList = useMemo(() => {
|
||||
const rotated = rotateToSelected(themes, themeId);
|
||||
return onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
|
||||
const browseOrder = useMemo(() => overlayList.map((entry) => entry.id), [overlayList]);
|
||||
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({
|
||||
onToggleFavorite: toggle,
|
||||
browseOrder,
|
||||
onToggleFilter: () => setOnlyFavorites((was) => !was),
|
||||
});
|
||||
|
||||
// The filter can pull the focused theme out from under the browse — flipping to favourites-only while
|
||||
// an unstarred one is focused. Land on the first of what is now shown rather than focusing nothing.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (overlayList.some((entry) => entry.id === focused)) return;
|
||||
const first = overlayList[0];
|
||||
if (first) setFocused(first.id);
|
||||
}, [open, overlayList, focused, setFocused]);
|
||||
|
||||
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
||||
|
||||
return (
|
||||
@@ -156,9 +184,9 @@ export const ThemeSwitcher = () => {
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
// No backdrop at all — the whole point of the browse is watching the app change behind the
|
||||
// list, so nothing may sit between them. The full-screen div stays for click-outside-to-close.
|
||||
className="fixed inset-0 z-[900] flex items-center justify-center"
|
||||
// Deliberately light: the point is to see the app change behind it.
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--background) 55%, transparent)' }}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
@@ -170,11 +198,31 @@ export const ThemeSwitcher = () => {
|
||||
}}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
{/* 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((entry) => {
|
||||
{/* The filter, mirrored from the dropdown — same state, so the two surfaces never disagree
|
||||
about what "the list" currently is. `f` does the same thing from the keyboard. */}
|
||||
<div className="mb-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOnlyFavorites((was) => !was)}
|
||||
className={`inline-flex cursor-pointer items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium transition-opacity ${
|
||||
onlyFavorites ? 'opacity-100' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||
{onlyFavorites
|
||||
? t('theme.favouritesOnly', { count: favorites.size })
|
||||
: t('theme.favourites', { count: favorites.size })}
|
||||
</button>
|
||||
</div>
|
||||
{overlayList.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs opacity-60">{t('theme.noFavourites')}</p>
|
||||
)}
|
||||
{/* FIXED dimensions, not max/content: flipping the favourites filter must not resize the
|
||||
panel — the list is 200+ themes on one side of the flip and a handful on the other, and
|
||||
a modal that jumps between two sizes on every `f` reads as broken. Long names truncate;
|
||||
they already did when the widest name happened to be filtered out. */}
|
||||
<ul className="flex h-[70vh] w-[26rem] max-w-[80vw] flex-col gap-1 overflow-y-auto">
|
||||
{overlayList.map((entry) => {
|
||||
const isFocused = entry.id === focused;
|
||||
return (
|
||||
<li key={entry.id}>
|
||||
@@ -206,9 +254,7 @@ export const ThemeSwitcher = () => {
|
||||
? t('theme.unstarNamed', { name: entry.name })
|
||||
: t('theme.starNamed', { name: entry.name })
|
||||
}
|
||||
title={
|
||||
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
||||
}
|
||||
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
||||
onClick={(ev) => {
|
||||
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||
// the opposite of what starring is for.
|
||||
|
||||
@@ -218,18 +218,23 @@ export const Spotlight = () => {
|
||||
// anywhere — including inside a panel that has its own key handling.
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
|
||||
// `code`, not `key`: ctrl+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.
|
||||
// Every other modifier must be ABSENT. `ctrl+shift+space` and `ctrl+alt+space` are the theme
|
||||
// browser, and one chord opening both is the bug that took a capture-phase listener to fix on the
|
||||
// theme side.
|
||||
//
|
||||
// Was shift+space until 2026-08-25: typing fires shift+space constantly (any capitalized word
|
||||
// followed by a space), so the launcher kept popping open mid-sentence. Ctrl+space doesn't happen in
|
||||
// normal typing.
|
||||
//
|
||||
// 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.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||
if (ev.ctrlKey && !ev.shiftKey && !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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LayoutNode, AppRegistryMeta } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
|
||||
// The screen every plugin route renders. THE PLUGIN DOES NOT RENDER A SCREEN.
|
||||
//
|
||||
@@ -27,7 +27,12 @@ export function PluginScreen({
|
||||
layout: LayoutNode;
|
||||
}) {
|
||||
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
|
||||
const workspace = useDashboardState<LayoutNode>(`screens/plugin/${appName}`, layout);
|
||||
//
|
||||
// `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is the plugin author's and comes from
|
||||
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
||||
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
||||
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
||||
const workspace = useAuthoredLayout(`screens/plugin/${appName}`, layout);
|
||||
const allowed = panels.map((panel) => panel.key);
|
||||
|
||||
return (
|
||||
|
||||
@@ -478,11 +478,11 @@
|
||||
"codeEditor": "Code-Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen · Strg+Umschalt+F {{starAction}}",
|
||||
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+↑/↓ durchsuchen · Strg+Umschalt+F {{starAction}}",
|
||||
"pillLabel": "Design: {{name}}",
|
||||
"star": "markieren",
|
||||
"unstar": "Markierung aufheben",
|
||||
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen",
|
||||
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+↑/↓ durchsuchen",
|
||||
"everyShortcutHint": "Strg+? für alle Tastenkombinationen",
|
||||
"favouritesOnly": "Nur Favoriten ({{count}})",
|
||||
"favourites": "Favoriten ({{count}})",
|
||||
@@ -494,7 +494,7 @@
|
||||
"addToFavouritesKey": "Zu Favoriten hinzufügen (f)",
|
||||
"removeFromFavouritesKey": "Aus Favoriten entfernen (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f markieren · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
|
||||
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f Favoriten · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gehe zu…",
|
||||
|
||||
@@ -478,11 +478,11 @@
|
||||
"codeEditor": "Code Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+↑/↓ browse · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Theme: {{name}}",
|
||||
"star": "star",
|
||||
"unstar": "unstar",
|
||||
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+space browse",
|
||||
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+↑/↓ browse",
|
||||
"everyShortcutHint": "ctrl+? for every shortcut",
|
||||
"favouritesOnly": "Favourites only ({{count}})",
|
||||
"favourites": "Favourites ({{count}})",
|
||||
@@ -494,7 +494,7 @@
|
||||
"addToFavouritesKey": "Add to favourites (f)",
|
||||
"removeFromFavouritesKey": "Remove from favourites (f)",
|
||||
"active": "active",
|
||||
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel"
|
||||
"browseHint": "j/k or ↑/↓ move · l or → preview · f favourites · h or ← reset · enter keep · esc cancel"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Go to…",
|
||||
|
||||
@@ -478,11 +478,11 @@
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ recorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ recorrer · ctrl+shift+↑/↓ explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ recorrer · ctrl+shift+space explorar",
|
||||
"cycleHint": "ctrl+shift+←/→ recorrer · ctrl+shift+↑/↓ explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos los atajos",
|
||||
"favouritesOnly": "Solo favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
@@ -494,7 +494,7 @@
|
||||
"addToFavouritesKey": "Añadir a favoritos (f)",
|
||||
"removeFromFavouritesKey": "Quitar de favoritos (f)",
|
||||
"active": "activo",
|
||||
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f marcar · h o ← reiniciar · enter mantener · esc cancelar"
|
||||
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f favoritos · h o ← reiniciar · enter mantener · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir a…",
|
||||
|
||||
@@ -478,11 +478,11 @@
|
||||
"codeEditor": "Éditeur de code"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Thème : {{name}} · ctrl+shift+←/→ parcourir · ctrl+shift+space explorer · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Thème : {{name}} · ctrl+shift+←/→ parcourir · ctrl+shift+↑/↓ explorer · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Thème : {{name}}",
|
||||
"star": "marquer",
|
||||
"unstar": "démarquer",
|
||||
"cycleHint": "ctrl+shift+←/→ parcourir · ctrl+shift+space explorer",
|
||||
"cycleHint": "ctrl+shift+←/→ parcourir · ctrl+shift+↑/↓ explorer",
|
||||
"everyShortcutHint": "ctrl+? pour tous les raccourcis",
|
||||
"favouritesOnly": "Favoris uniquement ({{count}})",
|
||||
"favourites": "Favoris ({{count}})",
|
||||
@@ -494,7 +494,7 @@
|
||||
"addToFavouritesKey": "Ajouter aux favoris (f)",
|
||||
"removeFromFavouritesKey": "Retirer des favoris (f)",
|
||||
"active": "actif",
|
||||
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f marquer · h ou ← réinitialiser · entrée garder · esc annuler"
|
||||
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f favoris · h ou ← réinitialiser · entrée garder · esc annuler"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Aller à…",
|
||||
|
||||
@@ -478,11 +478,11 @@
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+↑/↓ explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+space explorar",
|
||||
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+↑/↓ explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos os atalhos",
|
||||
"favouritesOnly": "Apenas favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
@@ -494,7 +494,7 @@
|
||||
"addToFavouritesKey": "Adicionar aos favoritos (f)",
|
||||
"removeFromFavouritesKey": "Remover dos favoritos (f)",
|
||||
"active": "ativo",
|
||||
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar"
|
||||
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f favoritos · h ou ← reiniciar · enter manter · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir para…",
|
||||
|
||||
@@ -478,11 +478,11 @@
|
||||
"codeEditor": "Kod-editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ bläddra · ctrl+shift+space bläddra i galleri · ctrl+shift+f {{starAction}}",
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ bläddra · ctrl+shift+↑/↓ bläddra i galleri · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "stjärnmärk",
|
||||
"unstar": "avmarkera stjärna",
|
||||
"cycleHint": "ctrl+shift+←/→ bläddra · ctrl+shift+space galleri",
|
||||
"cycleHint": "ctrl+shift+←/→ bläddra · ctrl+shift+↑/↓ galleri",
|
||||
"everyShortcutHint": "ctrl+? för alla kortkommandon",
|
||||
"favouritesOnly": "Endast favoriter ({{count}})",
|
||||
"favourites": "Favoriter ({{count}})",
|
||||
@@ -494,7 +494,7 @@
|
||||
"addToFavouritesKey": "Lägg till i favoriter (f)",
|
||||
"removeFromFavouritesKey": "Ta bort från favoriter (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f stjärna · h eller ← återställ · enter behåll · esc avbryt"
|
||||
"browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f favoriter · h eller ← återställ · enter behåll · esc avbryt"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gå till…",
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
titleKey: 'shortcuts.navigation',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Shift+Space',
|
||||
keys: 'Ctrl+Space',
|
||||
descriptionKey: 'shortcuts.spotlight',
|
||||
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
|
||||
},
|
||||
@@ -54,7 +54,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
noteKey: 'shortcuts.themesNote',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Ctrl+Shift+Space',
|
||||
keys: 'Ctrl+Shift+↑ / ↓',
|
||||
descriptionKey: 'shortcuts.openBrowser',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
listCommandHqSessions,
|
||||
listCommandHqSessionIds,
|
||||
listCommandHqOwnedSessionIds,
|
||||
listAllCommanders,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
deleteCommandHqSession,
|
||||
|
||||
@@ -213,6 +213,28 @@ export async function listCommandHqOwnedSessionIds(userId: number): Promise<Set<
|
||||
// drizzle's `eq` cannot express `IS NOT NULL` either.
|
||||
const isNotNullSessionId = () => sql`${commandHqSessions.sessionId} is not null`;
|
||||
|
||||
/**
|
||||
* Every commander on the machine, across every account — id, owner and directory only.
|
||||
*
|
||||
* The one query here that is not scoped to a user, and it exists for a caller that is not a request:
|
||||
* `servers/api/command-hq/watcher-revive.ts` ticks over all of them looking for a watch whose process
|
||||
* has gone. There is no `userId` to scope by at that point, and the reviver needs the owner's id in
|
||||
* order to resolve their email and their home — so it is returned rather than looked up per row.
|
||||
*
|
||||
* Narrow on purpose. A background sweep has no business reading names, categories or cost, and the next
|
||||
* caller that wants those should say so by widening deliberately.
|
||||
*/
|
||||
export async function listAllCommanders(): Promise<{ userId: number; sessionId: string; dirName: string }[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: commandHqSessions.userId,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
dirName: commandHqSessions.dirName,
|
||||
})
|
||||
.from(commandHqSessions);
|
||||
return rows.filter((row): row is { userId: number; sessionId: string; dirName: string } => row.sessionId !== null);
|
||||
}
|
||||
|
||||
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
|
||||
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
|
||||
import { startNudger } from './servers/api/chat/nudger';
|
||||
import { startWatcherRevive } from './servers/api/command-hq/watcher-revive';
|
||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
||||
import officerWeb from './apps/officer-web/index.gen.html';
|
||||
@@ -226,6 +227,7 @@ registerChatPushNotifications();
|
||||
// Nudger's tick. Here rather than on first use, because a schedule armed before a restart has to survive
|
||||
// one — the row is in Postgres and nothing else would ever look at it again.
|
||||
startNudger();
|
||||
startWatcherRevive();
|
||||
|
||||
// That the working directory is the repo, because every path derives from its parent. First of the three,
|
||||
// since a wrong answer here makes the other two check the wrong files.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPluginChatsDir } from '@@/data-path';
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession, sessionKeyForTranscript } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
@@ -108,7 +109,24 @@ export const chatRouter = createRouter();
|
||||
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
|
||||
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
||||
// selects there too.
|
||||
const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home;
|
||||
/**
|
||||
* Which directory a listing is about.
|
||||
*
|
||||
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
||||
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
||||
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
||||
* its own directory and then has no way to ask for them back.
|
||||
*
|
||||
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
||||
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
||||
* another directory shows you your own conversations elsewhere, never anybody else's.
|
||||
*/
|
||||
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
|
||||
const context = ctx.req.query('context');
|
||||
const contextId = ctx.req.query('contextId')?.trim();
|
||||
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
||||
return ctx.req.query('cwd')?.trim() || who.home;
|
||||
};
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', async (ctx) => {
|
||||
@@ -121,7 +139,7 @@ chatRouter.get('/pwds', async (ctx) => {
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await chatIdentity(user);
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const cwd = cwdOf(ctx, who);
|
||||
|
||||
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
||||
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
||||
@@ -205,7 +223,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await chatIdentity(user);
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const cwd = cwdOf(ctx, who);
|
||||
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
||||
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
|
||||
@@ -418,7 +436,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const cwd = cwdOf(ctx, who);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
if (isOpenCodeSessionId(id)) {
|
||||
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
||||
@@ -457,7 +475,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { CHAT_DEFAULT_MODEL, DEFAULT_MODEL, isTierlessModel } from './websocket';
|
||||
import { CHAT_DEFAULT_MODEL as CLIENT_CHAT_DEFAULT_MODEL } from 'officerdev';
|
||||
|
||||
/**
|
||||
* The default model, and the two ways it has already been defeated.
|
||||
*
|
||||
* Both failures were silent and both cost real money: every unconfigured surface ran Opus while the code
|
||||
* said Sonnet. Neither was visible in a diff — one was a constant in another package, the other was a
|
||||
* value stored in somebody's settings.
|
||||
*/
|
||||
|
||||
describe('the client and server agree on the default', () => {
|
||||
/*
|
||||
A comment cannot hold this. `apps/Chat/types.ts` mirrors the server's constant because the picker has
|
||||
to SHOW what the server would choose — and if it shows something else it also sends it, at which point
|
||||
the server honours the client and the "default" never applies. That is not hypothetical: the client
|
||||
fell back to the catalogue's first entry, which was `fable`, and did exactly that.
|
||||
*/
|
||||
test('the mirrored constant matches', () => {
|
||||
expect(CLIENT_CHAT_DEFAULT_MODEL).toBe(CHAT_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
test('it names a tier rather than the bare harness', () => {
|
||||
expect(CHAT_DEFAULT_MODEL).toContain('/');
|
||||
expect(isTierlessModel(CHAT_DEFAULT_MODEL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTierlessModel', () => {
|
||||
/*
|
||||
`claude-code` is a harness, and it is a legitimate value — `decideResume` assumes it when neither side
|
||||
knows which sidecar a session belongs to. As an answer to "which model", it means "whatever the CLI
|
||||
picks", which is Opus. Accounts have it stored from before that distinction existed, which is why the
|
||||
default appeared to do nothing on a machine where it was set.
|
||||
*/
|
||||
test('the bare harness is not a model choice', () => {
|
||||
expect(isTierlessModel(DEFAULT_MODEL)).toBe(true);
|
||||
expect(isTierlessModel('claude-code')).toBe(true);
|
||||
});
|
||||
|
||||
test('nothing chosen is not a model choice either', () => {
|
||||
expect(isTierlessModel(null)).toBe(true);
|
||||
expect(isTierlessModel(undefined)).toBe(true);
|
||||
expect(isTierlessModel('')).toBe(true);
|
||||
});
|
||||
|
||||
test('a real tier is', () => {
|
||||
expect(isTierlessModel('claude-code/sonnet')).toBe(false);
|
||||
expect(isTierlessModel('claude-code/opus[1m]')).toBe(false);
|
||||
// OpenCode ids carry a provider prefix of their own, so they are tiered by the same test.
|
||||
expect(isTierlessModel('opencode/some-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dueNudges, recordNudgeSent, getUserById } from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { startClaudeTurn } from './websocket';
|
||||
import { startClaudeTurn, CHAT_DEFAULT_MODEL } from './websocket';
|
||||
|
||||
// Nudger: the schedule that keeps a chat moving while nobody is watching it.
|
||||
//
|
||||
@@ -83,7 +83,9 @@ async function fire(nudge: { id: number; userId: number; sessionId: string; mess
|
||||
// they would see a turn started from another tab.
|
||||
ws: null,
|
||||
sessionId: nudge.sessionId,
|
||||
model: 'claude-code/sonnet',
|
||||
// The constant, not a copy of its value — a nudge is an unattended turn and belongs with every
|
||||
// other surface that never had a picker.
|
||||
model: CHAT_DEFAULT_MODEL,
|
||||
msg: { prompt: nudge.message, resumeSessionId: nudge.sessionId },
|
||||
effectivePrompt: nudge.message,
|
||||
onOutcome: (outcome) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { join } from 'path';
|
||||
import {
|
||||
getOwnerHomeDir,
|
||||
getEmailAccountsDir,
|
||||
getPluginChatsDir,
|
||||
findEmailAccountName,
|
||||
getCommanderDir,
|
||||
} from '../../../servers/data-path';
|
||||
@@ -40,13 +41,56 @@ import { resolveHomeDir } from '@@/user-home';
|
||||
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { snapshotPlugins } from '@@/plugins/mount';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import { logger } from './logger';
|
||||
import { isSuperAdmin } from '@@/super-admin';
|
||||
import { snapshotClaudeUsage } from './usage-store';
|
||||
|
||||
// Default model when no user preference is set
|
||||
/**
|
||||
* The bare harness, `claude-code` with no tier. **This is a HARNESS IDENTITY, not a model choice.**
|
||||
*
|
||||
* It is what `decideResume` assumes when neither the server nor the client knows which sidecar a resuming
|
||||
* session belongs to — see that function for the three failures a wrong guess here produced. Keep it bare:
|
||||
* adoption and delivery compare harnesses, and a tier suffix on this value would be comparing something
|
||||
* else. **What a NEW chat runs on is `CHAT_DEFAULT_MODEL` below**, which is a different question that
|
||||
* happened to share a constant.
|
||||
*/
|
||||
export const DEFAULT_MODEL = 'claude-code';
|
||||
|
||||
/**
|
||||
* What a chat runs on when nobody has said otherwise — no model from the client, no account default.
|
||||
*
|
||||
* Sonnet, since 2026-08-25. It used to be the bare harness, which means whatever Claude Code picks for
|
||||
* itself, and that is Opus: every unconfigured surface — a plugin's chat panel, a nudge, a chat opened
|
||||
* from anywhere that does not carry a picker — was quietly running the most expensive model available for
|
||||
* work that rarely needs it. The owner watched a commander spend ~$0.26 of Opus on a single routing
|
||||
* decision on 2026-08-25 and pinned commanders to Sonnet; this is the same reasoning applied to everything
|
||||
* that never had a picker in the first place.
|
||||
*
|
||||
* **It is a floor, not a ceiling.** An account default still wins, and a client that names a model still
|
||||
* wins over both, so nothing that could already choose loses the ability. This only changes what happens
|
||||
* when nothing chose.
|
||||
*
|
||||
* A better answer is wanted — per-surface defaults, or something that reads the work rather than the
|
||||
* caller. This is the honest interim: one constant, one place, easy to move.
|
||||
*/
|
||||
export const CHAT_DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
/**
|
||||
* Is this stored preference actually a model, or just a harness?
|
||||
*
|
||||
* `claude-code` with no tier routes fine and is a legitimate value for the resume path — but as an answer
|
||||
* to "which model should this chat use" it means "whatever the CLI picks", which is Opus. Accounts have it
|
||||
* stored from before that distinction existed: on this machine `tasks.defaultModel` and
|
||||
* `chat.defaultProjectModel` were both bare, which is why the picker showed `claude-code` and the change
|
||||
* to `CHAT_DEFAULT_MODEL` appeared to do nothing.
|
||||
*
|
||||
* Rather than rewrite somebody's settings, a bare harness is READ as unset. The row keeps whatever it
|
||||
* says; the meaning of "no tier chosen" is now "use the default", which is what it always meant.
|
||||
*/
|
||||
export const isTierlessModel = (model: string | null | undefined): boolean => !model || !model.includes('/');
|
||||
|
||||
/**
|
||||
* What a commander runs on, regardless of the account's default.
|
||||
*
|
||||
@@ -161,6 +205,54 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the plugin's own `CLAUDE.md` where its chats will run.
|
||||
*
|
||||
* ── Why a file and not a prompt prefix ──
|
||||
*
|
||||
* A plugin used to say what its chats should know through `promptPrefix`, which rides every message. That
|
||||
* has three costs and the owner hit all of them: it is paid per turn forever, it is INVISIBLE as a design
|
||||
* artefact because it lives in a TypeScript constant, and it is visible in the worst place — the owner
|
||||
* sent three words and watched a paragraph of preamble go out attached to them.
|
||||
*
|
||||
* Claude Code reads `CLAUDE.md` from its working directory at session start. So the same words, written
|
||||
* once into the directory these chats already run in, cost nothing per turn, can be opened and edited by
|
||||
* the person they are about, and are the mechanism `docs/commander-directory.md` already settled on for
|
||||
* exactly this problem.
|
||||
*
|
||||
* ── Copied, not read in place, and why that is not a contradiction ──
|
||||
*
|
||||
* `docs/plugin-capabilities.md` argues at length that a plugin's capabilities are READ where they live and
|
||||
* never copied. That rule is about files the PLATFORM reads, where a copy goes stale and survives an
|
||||
* uninstall. This one is read by Claude Code, from a cwd, so it has to exist there — no amount of
|
||||
* indirection changes where that program looks.
|
||||
*
|
||||
* The staleness the copy rule warns about is answered by rewriting it on every resolve rather than once at
|
||||
* install: the file on disk is always this version of the plugin's, and a `plugins:update` needs no
|
||||
* republish step to take effect. It is a few kilobytes written when a chat starts.
|
||||
*
|
||||
* Best-effort throughout. A plugin with no brief is the ordinary case, and a chat that cannot be given one
|
||||
* still runs — it simply knows less, which is where every chat was before this existed.
|
||||
*/
|
||||
async function installPluginChatBrief(appName: string, dir: string): Promise<void> {
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled);
|
||||
if (!state) return;
|
||||
|
||||
const source = join(state.plugin.dir, 'chat', 'CLAUDE.md');
|
||||
if (!existsSync(source)) return;
|
||||
|
||||
const target = join(dir, 'CLAUDE.md');
|
||||
const next = await Bun.file(source).text();
|
||||
// Skip the write when nothing changed, so a directory's mtimes do not churn on every chat start.
|
||||
if (existsSync(target) && (await Bun.file(target).text()) === next) return;
|
||||
await writeAtomic(target, next);
|
||||
} catch (err) {
|
||||
console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
||||
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
|
||||
async function resolveChatCwd(
|
||||
@@ -222,6 +314,28 @@ async function resolveChatCwd(
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
/*
|
||||
A plugin's own chats run in a directory the plugin owns — `$DATA_PATH/<email>/plugins/<appName>/chats`.
|
||||
|
||||
`contextId` is the plugin's appName and is the only thing taken from the client here. The PATH is built
|
||||
server-side from it, never sent: `docs/commander-directory.md` reversed exactly that call for
|
||||
commanders, and the reasoning carries — a client that can name a directory can name any directory, and
|
||||
`getPluginChatsDir` refuses a separator for the same reason `getCommanderDir` does.
|
||||
|
||||
Why a directory rather than more preamble: Claude Code reads a `CLAUDE.md` from its cwd at session
|
||||
start, so a plugin can give its chats context that costs nothing in every other conversation on the
|
||||
machine. It also keeps a plugin's chats off the account's home.
|
||||
|
||||
No `contextId` means no plugin, which falls through to the ordinary answer rather than inventing a
|
||||
directory named after nothing.
|
||||
*/
|
||||
if (msg.context === 'plugin' && msg.contextId) {
|
||||
const dir = getPluginChatsDir(email, msg.contextId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
await installPluginChatBrief(msg.contextId, dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
|
||||
if (msg.context === 'chat') {
|
||||
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
|
||||
@@ -842,8 +956,11 @@ async function handleChat(
|
||||
.join('\n')}`
|
||||
: withSummary;
|
||||
|
||||
// Use provided model, or fall back to user default, or the system default.
|
||||
const requested = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
||||
// Provided model, then the account default, then Sonnet. NOT `DEFAULT_MODEL` — that is the bare harness
|
||||
// and means "whatever Claude Code picks", which is Opus. See `CHAT_DEFAULT_MODEL`.
|
||||
const accountDefault = await getUserDefaultModel(userId);
|
||||
const chosen = msg.model || accountDefault;
|
||||
const requested = isTierlessModel(chosen) ? CHAT_DEFAULT_MODEL : chosen!;
|
||||
|
||||
/*
|
||||
A conversation may change TIER mid-flight; it may not change HARNESS.
|
||||
|
||||
@@ -214,6 +214,31 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves its loop running.',
|
||||
'',
|
||||
'### `WATCHERS.md` — how to start your own watch again',
|
||||
'',
|
||||
'A pid is not a watch. It is a number that stops meaning anything the moment the process ends, and it',
|
||||
'ends more often than you would think: the machine reboots, something kills the tree it was spawned',
|
||||
'under, the loop hits a bug and exits. When that happens the platform will notice, and it will hand',
|
||||
'the turn back to you asking you to start the watch again.',
|
||||
'',
|
||||
'**At that moment your only source is this file.** The transcript may be long past the turn that armed',
|
||||
'it, and the platform deliberately does not remember your command — it never starts a watch itself,',
|
||||
'because a process it spawned could not reach you when it fired.',
|
||||
'',
|
||||
'So keep `WATCHERS.md` true, and write it the moment you arm, change or stop a watch. One entry each:',
|
||||
'',
|
||||
'- **what it watches** — in a sentence, so a person can tell whether it is still wanted.',
|
||||
'- **the exact command**, verbatim, the way you ran it. Not a description of it.',
|
||||
'- **how often** it checks, and how it decides something has happened.',
|
||||
'- **what it does when it fires**, or which file says.',
|
||||
'- **when you armed it**, and anything you learned since that would change how you start it.',
|
||||
'',
|
||||
'The test: could you restore this watch from that entry alone, having read nothing else? If not, the',
|
||||
'entry is a note rather than a record, and the watch dies with the process.',
|
||||
'',
|
||||
'Rewrite the entry when you re-arm. A stale command in this file is worse than no file, because it',
|
||||
'will be followed.',
|
||||
'',
|
||||
...credentialSection(params.credentialPath),
|
||||
'## The shape: a trigger, a ledger, and up to three steps between them',
|
||||
'',
|
||||
@@ -345,6 +370,8 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
' the job is settled and kept true after that.',
|
||||
'CLASSIFICATION.md the category rules, if it classifies. yours, and expected to change.',
|
||||
'DISPATCHING.md what happens to a result beyond keeping it, if anything. yours.',
|
||||
'WATCHERS.md every watch you are running, exactly enough to start it again. yours,',
|
||||
' rewritten whenever you arm, change or stop one.',
|
||||
'data/ everything this commander has produced. ALWAYS called data.',
|
||||
' <category>/ only if it classifies — one directory per category, as they are agreed',
|
||||
' unclassified/ only if it classifies — produced and recorded, placement still open',
|
||||
|
||||
@@ -187,5 +187,26 @@ export function stopCommanderProcesses(email: string, dirName: string): number {
|
||||
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
|
||||
// The record goes with the processes, and this is load-bearing rather than tidiness.
|
||||
//
|
||||
// `watcher-revive.ts` treats "a pid file whose pids are all dead" as a watch that died and needs the
|
||||
// commander woken. A Stop that killed the loop and left the file behind is indistinguishable from a
|
||||
// crash, so the reviver would hand the turn back and the commander would dutifully re-arm the thing
|
||||
// the owner just stopped. Removing the file is what makes deliberate and accidental different.
|
||||
clearLoopPid(email, dirName);
|
||||
return signalled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the recorded pid. "No file" means no watch, on purpose — which is exactly what a fresh
|
||||
* commander looks like, and what a stopped one should look like too.
|
||||
*/
|
||||
export function clearLoopPid(email: string, dirName: string): void {
|
||||
try {
|
||||
rmSync(loopPidPath(email, dirName), { force: true });
|
||||
} catch {
|
||||
// A file we cannot remove is not worth failing a Stop over. The reviver's own guard covers it: it
|
||||
// wakes a commander once and clears the file itself, so the worst case is one wasted turn.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { listAllCommanders, getUserById } from 'officerdb';
|
||||
import { getCommanderStateDir } from '@@/data-path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { clearLoopPid, liveLoopPids } from './spawned';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
|
||||
// Waking a commander whose watch has died.
|
||||
//
|
||||
// ── The failure this exists for ──
|
||||
//
|
||||
// A watch is a background process the commander started, and nothing on this machine supervises it. So a
|
||||
// reboot ends every one of them at once, and nothing says so: the card simply stops saying "watching",
|
||||
// which looks identical to a commander that was never armed.
|
||||
//
|
||||
// That is not theoretical. On 2026-08-24 this machine rebooted at 12:40 and took the deploy commander's
|
||||
// loop with it. A release was tagged the next day and sat on the remote with nobody looking at it, and
|
||||
// the only evidence was a word missing from a card. `todo/agent-coordination/` carries the full account.
|
||||
//
|
||||
// ── Why the platform wakes the agent instead of restarting the process ──
|
||||
//
|
||||
// The obvious fix is for the platform to respawn the loop itself, and it is WRONG — this was settled on
|
||||
// 2026-08-18 and is written down in `docs/command-hq.md` under "The watcher loop". The agent is only ever
|
||||
// notified about a process it started and is itself tracking. A process the platform spawned sits outside
|
||||
// that entirely, so it would watch correctly, fire, and reach nobody.
|
||||
//
|
||||
// A respawn would therefore trade a visible failure for an invisible one, which is the worse of the two:
|
||||
// today the commander is plainly not watching, and that version would be watching and unable to tell
|
||||
// anyone. So the platform's whole job here is to NOTICE and hand the turn back. The agent re-arms itself,
|
||||
// exactly as it does after every ordinary fire, and the process it starts is tracked.
|
||||
//
|
||||
// The owner's two conditions, 2026-08-25, both kept by construction: the commander is still the only
|
||||
// thing that starts a watch, and the indicator still reads the pid file rather than any stored claim.
|
||||
//
|
||||
// ── Why the pid file, and not `watcherArmed` ──
|
||||
//
|
||||
// `watcherArmed` is a claim only one path sets, and most commanders never take that path — they arm by
|
||||
// backgrounding a process, which records a pid and nothing else. Both commanders on this machine read
|
||||
// `watcher_armed = false` while one of them was demonstrably watching. `router.ts` already resolves this
|
||||
// the same way for its cards: **the pid file is the fact.**
|
||||
//
|
||||
// So the signal is: a pid file EXISTS and nothing in it is alive. That means this commander had a watch
|
||||
// and no longer does. A commander that never armed has no file, and a commander the owner deliberately
|
||||
// stopped no longer has one either — `stopCommanderProcesses` removes it, which is what makes stopped and
|
||||
// crashed distinguishable at all.
|
||||
|
||||
const TICK_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The first tick is delayed rather than immediate.
|
||||
*
|
||||
* A reboot is the case this is for, and at boot the platform comes up before much else — waking a
|
||||
* commander into a machine still starting its own services would ask it to re-arm a watch against
|
||||
* half-present dependencies. Two minutes is not a considered number; it is enough for the rest of PM2.
|
||||
*/
|
||||
const FIRST_TICK_MS = 120_000;
|
||||
|
||||
const PID_FILE = 'loop-pid';
|
||||
const ATTEMPTS_FILE = 'revive-attempts';
|
||||
|
||||
/**
|
||||
* How many times this will wake one commander, and over what window.
|
||||
*
|
||||
* **This bound is the backstop, not the correctness.** The guard that matters is `isGenerating` below;
|
||||
* this one exists for the case that guard cannot see — a commander that genuinely cannot re-arm, waking,
|
||||
* failing, and being woken again every two minutes, each wake costing a real agent turn, all night.
|
||||
*
|
||||
* That is the exact failure `prompts/deploy-commander.md` records for the first deploy watcher: a careful
|
||||
* loop with an unbounded retry inside it. A commander that cannot re-arm itself is not a thing to keep
|
||||
* asking; it is a thing to tell a person about once.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const ATTEMPT_WINDOW_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/** Wake timestamps inside the window. Anything older is dropped rather than kept — this is a rate, not a history. */
|
||||
function recentAttempts(email: string, dirName: string): number[] {
|
||||
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
|
||||
if (!existsSync(path)) return [];
|
||||
const cutoff = Date.now() - ATTEMPT_WINDOW_MS;
|
||||
try {
|
||||
return readFileSync(path, 'utf-8')
|
||||
.split('\n')
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > cutoff);
|
||||
} catch {
|
||||
// Unreadable counts as "we have tried" rather than "we have not". Failing towards fewer wakes is the
|
||||
// safe direction: the cost of one missed revival is a watch that stays off and is reported; the cost
|
||||
// of miscounting the other way is a turn every two minutes.
|
||||
return new Array(MAX_ATTEMPTS).fill(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
function recordAttempt(email: string, dirName: string): void {
|
||||
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
|
||||
try {
|
||||
writeFileSync(path, [...recentAttempts(email, dirName), Date.now()].join('\n'));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[watcher-revive] could not record an attempt for ${dirName}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A commander that re-armed successfully starts from zero, so a crash months later is not refused. */
|
||||
function clearAttempts(email: string, dirName: string): void {
|
||||
try {
|
||||
writeFileSync(join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE), '');
|
||||
} catch {
|
||||
// Not worth failing over — the window expires on its own.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A watch that was running and is not.
|
||||
*
|
||||
* **A dead pid is NOT the same as a dead watch, and this is the trap.** A watcher is built to EXIT when it
|
||||
* fires — that is the whole mechanism, because a background process reaches the model only by ending. So
|
||||
* the normal, healthy path goes: poll, find something, exit, hand back to the agent, agent acts, agent
|
||||
* re-arms. For the whole of that middle stretch the pid file names a process that is gone.
|
||||
*
|
||||
* The exit-on-fire model is `docs/command-hq.md` → "The watcher loop", settled 2026-08-18, and it is the
|
||||
* reason this guard exists rather than any incident. There WAS a near miss on 2026-08-25 — commander 40
|
||||
* was woken, rebuilt its watch, and two seconds after it recorded a pid that pid read as dead — but the
|
||||
* process turned out to be alive and mid-claim, so the honest account is that a pid file is a poor instant
|
||||
* answer to "is this watching", not that a fire was observed being misread. Either way the window is real:
|
||||
* between a watcher exiting and its agent re-arming, the file names a process that is gone.
|
||||
*
|
||||
* Hence the generating check: if a TURN is running, the exit is being handled and the re-arm is the last
|
||||
* thing that turn will do. Waking then is not just wasteful, it is wrong — it would arrive mid-work
|
||||
* carrying an instruction the agent has no reason to obey.
|
||||
*
|
||||
* ── "generating" is not "has an outstanding background task" ──
|
||||
*
|
||||
* This asked `collectRunningTasks` until 2026-08-26, and that was wrong in a way that disabled the whole
|
||||
* feature. Those are BACKGROUND TASKS, and **a commander's watcher is itself a background task** — one that
|
||||
* stays outstanding until it reports a terminal state, which a killed process never does. So every
|
||||
* commander with an armed watcher had one outstanding by definition, the check returned "busy" forever, and
|
||||
* the reviver could never wake the one thing it exists for.
|
||||
*
|
||||
* Found on the deploy commander: its loop was dead, its pid file said so, and the task
|
||||
* "Platform Deployer watcher — polls origin for deploy/* tags" was still marked running from the revival
|
||||
* six hours earlier. The guard was permanently true.
|
||||
*
|
||||
* The right question is whether the AGENT is mid-turn, which the sidecar answers directly.
|
||||
*/
|
||||
async function watchDied(email: string, dirName: string, sessionId: string, userId: number): Promise<boolean> {
|
||||
if (!existsSync(join(getCommanderStateDir(email, dirName), PID_FILE))) return false;
|
||||
if (liveLoopPids(email, dirName).length > 0) return false;
|
||||
|
||||
try {
|
||||
const live = await sidecar.listLiveClaudeSessions(userId);
|
||||
const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId);
|
||||
// Absent means idle or collected, and both are fine — `fire` resumes by transcript id.
|
||||
if (mine?.isGenerating) return false;
|
||||
} catch {
|
||||
// Cannot tell whether it is working, so assume it is. A missed revival is reported next tick; a wake
|
||||
// delivered into a live turn is not recoverable by waiting.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the commander is told. Deliberately short, and it points at the file rather than describing the
|
||||
* watch — the platform does not know what was being watched and must not guess.
|
||||
*
|
||||
* `WATCHERS.md` is the commander's own record, required by its `CLAUDE.md`: what each watch is, the exact
|
||||
* command, how often, and what happens when it fires. This message is the moment that file earns its keep.
|
||||
*
|
||||
* It also has to work for a commander that predates the file, which every commander on this machine does
|
||||
* today — so reconstructing from `JOB.md`, a script in its own directory, or the turn that armed it is
|
||||
* explicitly allowed. That is reading its own records, not guessing. Inventing a command it cannot find
|
||||
* is the thing forbidden, because a wrong watch looks exactly like a working one.
|
||||
*/
|
||||
const WAKE_PROMPT = [
|
||||
'Your watch is not running. The platform found a recorded pid with no live process behind it —',
|
||||
'usually the machine rebooted, or something killed the tree it was started under.',
|
||||
'',
|
||||
'Read `WATCHERS.md` and start it again, exactly as it is written there: same command, same interval,',
|
||||
'same behaviour on firing. Then record the new pid as your `CLAUDE.md` describes, and update the entry',
|
||||
'if anything about it changed.',
|
||||
'',
|
||||
'If `WATCHERS.md` does not exist, you may rebuild the watch from your own records — `JOB.md`, a script',
|
||||
'in your directory, the turn in this conversation that armed it. That is reading, not guessing, and',
|
||||
'write `WATCHERS.md` as you go so this is the last time it has to be reconstructed.',
|
||||
'',
|
||||
'But if none of those say what the command actually was, do NOT invent one. Say what is missing and',
|
||||
'stop. A wrong watch is worse than none, because it looks like one that works.',
|
||||
].join('\n');
|
||||
|
||||
async function wake(userId: number, sessionId: string, dirName: string, email: string): Promise<void> {
|
||||
// Cleared BEFORE the turn, not after. The file is the trigger, so leaving it in place while a turn runs
|
||||
// means the next tick sees the same dead pid and wakes the commander a second time. Clearing first also
|
||||
// gives the right outcome when the wake fails: one attempt, one notification, and silence — rather than
|
||||
// a commander woken every two minutes for as long as the machine is up.
|
||||
clearLoopPid(email, dirName);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(done, 60_000);
|
||||
|
||||
void startClaudeTurn({
|
||||
email,
|
||||
username: email,
|
||||
userId,
|
||||
// No socket, the same way the nudger drives a turn. Whoever has the commander open sees it arrive
|
||||
// through their own connection.
|
||||
ws: null,
|
||||
sessionId,
|
||||
model: COMMANDER_DEFAULT_MODEL,
|
||||
msg: { prompt: WAKE_PROMPT, resumeSessionId: sessionId },
|
||||
effectivePrompt: WAKE_PROMPT,
|
||||
onOutcome: (outcome) => {
|
||||
clearTimeout(timer);
|
||||
if (!outcome.ok) console.error(`[watcher-revive] ${dirName}: ${outcome.error}`);
|
||||
done();
|
||||
},
|
||||
}).catch((err) => {
|
||||
clearTimeout(timer);
|
||||
console.error(`[watcher-revive] ${dirName} threw:`, err instanceof Error ? err.message : err);
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the owner, separately from waking the commander.
|
||||
*
|
||||
* These are two different audiences and only one of them can act when the wake does not work. A
|
||||
* commander that cannot find its command says so into a transcript nobody is reading at 4am; the
|
||||
* notification is what makes "your watch has been off since the reboot" reach a person.
|
||||
*
|
||||
* Best-effort in both directions: the wake happens whether or not this lands, and a failure here is
|
||||
* logged rather than raised. Null headers mean the notify sidecar has not registered yet, and sending
|
||||
* unauthenticated is not the fallback — see `notifyProducerHeaders`.
|
||||
*/
|
||||
async function tellTheOwner(userId: number, dirName: string): Promise<void> {
|
||||
const base = getNotifyServerUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
if (!base || !producer) return;
|
||||
|
||||
try {
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...producer },
|
||||
body: JSON.stringify({
|
||||
code: 'officer/core:message',
|
||||
userId,
|
||||
title: 'A commander stopped watching',
|
||||
// Names the commander by its directory and says nothing about what it watches. The subject of a
|
||||
// watch is the owner's business and this text goes through Apple and Google to a lock screen.
|
||||
body: `Commander ${dirName}'s watch was not running. It has been asked to start it again.`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[watcher-revive] could not notify about ${dirName}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start the sweep. Called once from `server.tsx`.
|
||||
*
|
||||
* `running` guards a slow tick against the next one, and `unref` keeps it from holding the process open.
|
||||
* Both copied from the nudger, which has the same shape and the same failure to avoid.
|
||||
*/
|
||||
export function startWatcherRevive(): void {
|
||||
if (timer) return;
|
||||
let running = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
for (const commander of await listAllCommanders()) {
|
||||
const user = await getUserById(commander.userId);
|
||||
if (!user) continue;
|
||||
if (!(await watchDied(user.email, commander.dirName, commander.sessionId, commander.userId))) {
|
||||
// Healthy, stopped, or mid-turn. A live loop means the last wake worked, so the count resets.
|
||||
if (liveLoopPids(user.email, commander.dirName).length > 0) clearAttempts(user.email, commander.dirName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attempts = recentAttempts(user.email, commander.dirName);
|
||||
if (attempts.length >= MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[watcher-revive] commander ${commander.dirName}: ${attempts.length} failed wakes — leaving it alone`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.warn(`[watcher-revive] commander ${commander.dirName}: watch is dead — waking it`);
|
||||
recordAttempt(user.email, commander.dirName);
|
||||
await tellTheOwner(commander.userId, commander.dirName);
|
||||
await wake(commander.userId, commander.sessionId, commander.dirName, user.email);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[watcher-revive] tick failed:', err instanceof Error ? err.message : err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => void tick(), FIRST_TICK_MS).unref?.();
|
||||
timer = setInterval(() => void tick(), TICK_MS);
|
||||
timer.unref?.();
|
||||
console.log('[watcher-revive] armed — checking every 120s for watches that have died');
|
||||
}
|
||||
@@ -12,13 +12,26 @@ import type { TurnMessage, MessageCost } from '../chat/types';
|
||||
import * as jobManager from './pipeline-job-manager';
|
||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
/**
|
||||
* What a task runs on when the account has set no task default.
|
||||
*
|
||||
* Sonnet since 2026-08-25, for the same reason as `CHAT_DEFAULT_MODEL` in `api/chat/websocket.ts`: the
|
||||
* previous value was the bare harness, which means whatever Claude Code chooses for itself, and that is
|
||||
* Opus. A task is the surface where that costs most — it runs unattended, often repeatedly, and nobody is
|
||||
* watching the bill.
|
||||
*
|
||||
* The account default still wins, so anybody who chose a model keeps it.
|
||||
*/
|
||||
const DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
async function resolveModel(userId: number): Promise<string> {
|
||||
try {
|
||||
const settings = await getUserSettings(userId);
|
||||
const tasks = settings?.tasks as Record<string, unknown> | undefined;
|
||||
return (tasks?.defaultModel as string) || DEFAULT_MODEL;
|
||||
// A bare `claude-code` is a harness, not a tier — read as unset. See `isTierlessModel` in
|
||||
// `api/chat/websocket.ts`; this machine had exactly that stored, so the default never applied.
|
||||
const stored = tasks?.defaultModel as string | undefined;
|
||||
return stored && stored.includes('/') ? stored : DEFAULT_MODEL;
|
||||
} catch {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,41 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
*/
|
||||
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
||||
|
||||
/**
|
||||
* Where a plugin's own chats run — `$DATA_PATH/<email>/plugins/<appName>/chats`.
|
||||
*
|
||||
* ── Why a directory at all ──
|
||||
*
|
||||
* A chat's working directory is the platform's proven way to give a conversation context it did not have
|
||||
* to be told: Claude Code reads a `CLAUDE.md` from its cwd at session start, which is the whole mechanism
|
||||
* behind `docs/commander-directory.md`. A plugin that wants its chats to know something specific writes it
|
||||
* there once, rather than paying for it in every preamble on the machine.
|
||||
*
|
||||
* It also keeps those chats out of the account's home, so a plugin's conversation is not casually sitting
|
||||
* on top of everything its owner has.
|
||||
*
|
||||
* ── Why it is a SIBLING of home, and what that costs ──
|
||||
*
|
||||
* `confineUserTree` chmods every sibling of `home` to 0700 owned by the service user, with `commanders`
|
||||
* carved out because a member has to reach it. **`plugins` is carved out the same way, and must stay that
|
||||
* way.** Without it this repeats a failure already on record: `/chat` once ran in
|
||||
* `DATA_PATH/<email>/general_chat_sessions`, and the first live member turn died on its own working
|
||||
* directory before doing anything, because a sibling of the home is the platform's by default.
|
||||
*
|
||||
* ── `appName` is not a path ──
|
||||
*
|
||||
* Same guard as `getCommanderDir`, for the same reason: this value reaches here from a plugin manifest and
|
||||
* a client message, and a separator in it would resolve outside the account's tree entirely.
|
||||
*/
|
||||
export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins');
|
||||
|
||||
export const getPluginChatsDir = (email: string, appName: string): string => {
|
||||
if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') {
|
||||
throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`);
|
||||
}
|
||||
return join(getPluginsDir(email), appName, 'chats');
|
||||
};
|
||||
|
||||
/**
|
||||
* One commander's own directory — `CLAUDE.md`, `state/`, and whatever it writes for itself. `dirName` is
|
||||
* `command_hq_sessions.dir_name`, derived from the row's own serial id at creation. See
|
||||
|
||||
+26
-6
@@ -2,7 +2,7 @@ import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { secretStorePath } from 'officerdb/secret-store';
|
||||
import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, toShellUsername } from './data-path';
|
||||
import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, getPluginsDir, toShellUsername } from './data-path';
|
||||
|
||||
// Real Linux accounts for members, so the surfaces that execute code can run as them.
|
||||
//
|
||||
@@ -337,13 +337,17 @@ export async function confineUserTree(params: {
|
||||
};
|
||||
}
|
||||
|
||||
// Every sibling of `home` is the platform's, confined 700 to the service user — EXCEPT `commanders`,
|
||||
// which gets the same member ownership as `home` itself, just below. Both are excluded from this loop
|
||||
// for the same reason: this pass runs unconditionally on every provision, and re-confining a directory
|
||||
// a member is meant to reach would silently undo the block that grants it.
|
||||
// Every sibling of `home` is the platform's, confined 700 to the service user — EXCEPT `commanders`
|
||||
// and `plugins`, which get the same member ownership as `home` itself, just below. All are excluded
|
||||
// from this loop for the same reason: this pass runs unconditionally on every provision, and
|
||||
// re-confining a directory a member is meant to reach would silently undo the block that grants it.
|
||||
//
|
||||
// `plugins` holds each plugin's chat working directories (`getPluginChatsDir`). A member's chat runs
|
||||
// THERE, so leaving it 700-service-user repeats the `general_chat_sessions` failure exactly: the turn
|
||||
// dies on its own cwd before running anything, and it reads as the agent being broken.
|
||||
const entries = await readdir(accountDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'home' || entry.name === 'commanders') continue;
|
||||
if (entry.name === 'home' || entry.name === 'commanders' || entry.name === 'plugins') continue;
|
||||
if (!entry.isDirectory()) continue;
|
||||
await chmod(join(accountDir, entry.name), 0o700);
|
||||
}
|
||||
@@ -430,6 +434,22 @@ export async function confineUserTree(params: {
|
||||
const closeCommanders = await run(['sudo', '-n', 'chmod', '700', commandersDir]);
|
||||
if (!closeCommanders.ok) return { ok: false, error: `chmod of ${commandersDir} failed: ${closeCommanders.out}` };
|
||||
|
||||
// ── And the same for `plugins`, for the same reason ──
|
||||
//
|
||||
// A plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats` (`getPluginChatsDir`), which is
|
||||
// a sibling of `home` exactly as `commanders` is, and a member's turn there runs as the MEMBER. So it
|
||||
// needs their ownership or it is the `general_chat_sessions` failure a third time — a working directory
|
||||
// the turn cannot `cd` into, presenting as an agent broken on its first command.
|
||||
//
|
||||
// Recursive, because plugins create their own subdirectories under here and a directory made by the
|
||||
// platform process would otherwise belong to the service user inside a tree the member owns.
|
||||
const pluginsDir = getPluginsDir(params.email);
|
||||
if (!existsSync(pluginsDir)) await mkdir(pluginsDir, { recursive: true });
|
||||
const givePlugins = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, pluginsDir]);
|
||||
if (!givePlugins.ok) return { ok: false, error: `chown of ${pluginsDir} failed: ${givePlugins.out}` };
|
||||
const closePlugins = await run(['sudo', '-n', 'chmod', '700', pluginsDir]);
|
||||
if (!closePlugins.ok) return { ok: false, error: `chmod of ${pluginsDir} failed: ${closePlugins.out}` };
|
||||
|
||||
// ── One directory where container bind mounts can live ──
|
||||
//
|
||||
// Created for every account, not only the ones that get a daemon: it is two `install` calls, and
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mintApiKey } from '@@/auth-token';
|
||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
|
||||
/**
|
||||
* A credential for one agent session, narrowed to notifications.
|
||||
* A credential for one agent session, carrying its holder's authority minus administration.
|
||||
*
|
||||
* ── The gap it closes ──
|
||||
*
|
||||
@@ -12,13 +12,39 @@ import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
* agent asked to tell its owner something had no door at all — it either could not, or it asked to be
|
||||
* handed a personal API key, which is the owner's full authority pasted into a transcript.
|
||||
*
|
||||
* ── Why `notify` and nothing else ──
|
||||
* ── It was `['notify']` until 2026-08-25, and the reason it stopped being enough ──
|
||||
*
|
||||
* "An agent may tell me things" is a much smaller thing to hand out than "an agent may act as me", and it
|
||||
* is the whole of what was being asked for. Everything else an agent needs, it already has: it runs as
|
||||
* somebody, in their home, with their files. This is only the one capability that has no other route.
|
||||
* The original argument was that "an agent may tell me things" is far smaller than "an agent may act as
|
||||
* me", and that everything else an agent needs it already has, because it runs as somebody, in their home,
|
||||
* with their files. Both halves were true. The second half is also what undid the first.
|
||||
*
|
||||
* Widening it later is one entry in this array. Starting wide and narrowing later never happens.
|
||||
* An owner's agent has a shell AS THE OWNER, unsandboxed. It can read `.env`, query Postgres, decrypt a
|
||||
* plugin's stored credential, `docker exec` into a container. So a key scoped to `notify` never made that
|
||||
* agent safer — it made it unable to use the platform POLITELY while every impolite route stayed open. The
|
||||
* moment that became visible: a chat asked to take a proxy host out of service correctly reported it could
|
||||
* not, having read this file, while sitting one `docker exec` away from the same container.
|
||||
*
|
||||
* A route that goes through the platform is authorised, logged, and bounded by a permission. One that goes
|
||||
* around it is none of those. Withholding the first does not prevent the second; it only chooses which one
|
||||
* gets used.
|
||||
*
|
||||
* ── What it carries now ──
|
||||
*
|
||||
* Its holder's own authority, minus `admin`. Not a list of permissions — a rule, resolved against the live
|
||||
* registry at request time, so a plugin installed next year works from a chat without this file changing.
|
||||
* The alternative on offer was adding each plugin's key to an array here, which puts plugin names in a core
|
||||
* file and hands every plugin to every chat, one line at a time, forever.
|
||||
*
|
||||
* `execution` needs no exclusion: it is never grantable at any level and `authorize.ts` strips it
|
||||
* regardless. `admin` is the one worth naming — an agent may use the server, and may not administer it.
|
||||
* Creating accounts, granting permissions and installing plugins stay with a person.
|
||||
*
|
||||
* ── This is narrower for a MEMBER, and that is where it matters ──
|
||||
*
|
||||
* A member's agent runs as their own Linux user, confined by the kernel, without the owner's environment.
|
||||
* For them this key is a real boundary rather than a courtesy: it resolves to exactly what their role was
|
||||
* granted, so a chat can reach what its owner allowed and nothing else. That is the shape the owner asked
|
||||
* for — "the features provided by the platform, including the ones that I allow for each user".
|
||||
*
|
||||
* ── Why an env var rather than a file ──
|
||||
*
|
||||
@@ -33,7 +59,12 @@ import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
* identical rows. Also expires on its own: a session that ends without cleanup — a crash, a restart — must
|
||||
* not leave a live credential behind for a month.
|
||||
*/
|
||||
const SESSION_KEY_PERMISSIONS = ['notify'];
|
||||
/**
|
||||
* Kinds this key may never reach, resolved against the registry on every request rather than expanded
|
||||
* here. See `api_keys.excludedKinds` — it is checked BEFORE the owner bypass, which is what makes it the
|
||||
* only thing capable of bounding a Super Admin's agent at all.
|
||||
*/
|
||||
const SESSION_KEY_EXCLUDED_KINDS = ['admin'];
|
||||
|
||||
/** A week. Long enough for a conversation somebody returns to, short enough that a leaked one dies. */
|
||||
const SESSION_KEY_DAYS = 7;
|
||||
@@ -59,7 +90,17 @@ export async function mintSessionCredential(userId: number, sessionKey: string):
|
||||
|
||||
const { secret, keyHash, prefix } = mintApiKey();
|
||||
const expiresAt = new Date(Date.now() + SESSION_KEY_DAYS * 24 * 60 * 60 * 1000);
|
||||
await createApiKey({ userId, name, keyHash, prefix, permissions: SESSION_KEY_PERMISSIONS, expiresAt });
|
||||
// `permissions: null` — no allow list. The narrowing is the kind rule below, so this key follows its
|
||||
// holder's grants as they change rather than freezing a snapshot of them at mint time.
|
||||
await createApiKey({
|
||||
userId,
|
||||
name,
|
||||
keyHash,
|
||||
prefix,
|
||||
permissions: null,
|
||||
excludedKinds: SESSION_KEY_EXCLUDED_KINDS,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const publicHost = (() => {
|
||||
try {
|
||||
|
||||
@@ -33,7 +33,22 @@ export type ChatSessionSelection = {
|
||||
|
||||
const title = (s: { title: string }) => s.title || '(untitled)';
|
||||
|
||||
export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
/**
|
||||
* `chatContext` is for a panel whose directory the CLIENT cannot name.
|
||||
*
|
||||
* A dashboard is rooted in a home-relative path the browser already knows, so the cwd derivation below
|
||||
* works. A plugin's chats live in a sibling of the home that the server builds and never sends — so
|
||||
* without this the header would list nothing at all, which is the failure the cwd comment right below
|
||||
* describes for a different reason. Given a context, the whole derivation is skipped and the server
|
||||
* resolves the same directory it resolved for the turn.
|
||||
*/
|
||||
export const ChatHeader = ({
|
||||
panelId,
|
||||
chatContext,
|
||||
}: {
|
||||
panelId?: string;
|
||||
chatContext?: { context: string; contextId?: string } | null;
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { workspace, cwd } = useWorkspace();
|
||||
|
||||
@@ -52,7 +67,9 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
const relative = cwdToPath(cwd);
|
||||
const absoluteCwd = relative === '/' ? null : defaultCwd ? `${defaultCwd.replace(/\/$/, '')}${relative}` : null;
|
||||
|
||||
const { sessions, renameSession, deleteSession } = useClaudeSessions(absoluteCwd);
|
||||
const { sessions, renameSession, deleteSession } = useClaudeSessions(absoluteCwd, {
|
||||
chatContext: chatContext ?? null,
|
||||
});
|
||||
const [sessionId, setSessionId] = usePanelSession(panelId, workspace);
|
||||
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
|
||||
@@ -151,9 +151,39 @@ const ChatPanelInner = ({
|
||||
// `promptPrefix` is a system prompt only this app understands, so the screen that wants one passes it
|
||||
// here rather than through the workspace context — a screen supplies its own chat panel through
|
||||
// `components`, which is what that prop exists for. The registry mounts this with no prefix.
|
||||
type ChatPanelWrapperProps = { panelId: string; promptPrefix?: string };
|
||||
type ChatPanelWrapperProps = {
|
||||
panelId: string;
|
||||
promptPrefix?: string;
|
||||
/**
|
||||
* Declared by a screen that owns its own chat directory — currently a plugin's panel.
|
||||
*
|
||||
* This is the "general fix" the `chatContext` comment below asks for. It said a plugin declaring a chat
|
||||
* context did not exist, and that a SECOND hardcoded screen id here would be the signal it was needed.
|
||||
* Rather than add that second id, a caller can now say what it is, and the server resolves the directory
|
||||
* from the same pair it uses for the turn. The `'email'` branch stays where it is: it is a screen the
|
||||
* platform mounts, not a caller that could pass this.
|
||||
*/
|
||||
chatContext?: { context: string; contextId?: string };
|
||||
/**
|
||||
* Called when a turn ends, with whether it ran any tools.
|
||||
*
|
||||
* The platform's own refreshes — the file tree, the preview — always happen; this is composed on top for
|
||||
* a screen whose panels show something a turn can change. A plugin's lists are the case: an agent that
|
||||
* deletes a proxy host leaves every panel beside it describing a world that no longer exists, and the
|
||||
* only signal a panel gets is this one.
|
||||
*
|
||||
* `hadToolCalls` is the whole discrimination worth making. A turn that only talked changed nothing, and
|
||||
* re-reading after every sentence would put a request behind every reply.
|
||||
*/
|
||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProps) => {
|
||||
export const ChatPanelWrapper = ({
|
||||
panelId,
|
||||
promptPrefix,
|
||||
chatContext: declared,
|
||||
onTurnComplete: alsoOnTurnComplete,
|
||||
}: ChatPanelWrapperProps) => {
|
||||
const { workspace, cwd, root } = useWorkspace();
|
||||
const scoped = cwd !== '~';
|
||||
|
||||
@@ -171,8 +201,9 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
//
|
||||
// The general fix is a way for a plugin to declare a chat context, which does not exist. Until it does,
|
||||
// adding a SECOND id here is the signal that it needs to.
|
||||
const chatContext =
|
||||
workspace?.kind === 'screen' && workspace.id === 'email'
|
||||
const chatContext = declared
|
||||
? declared
|
||||
: workspace?.kind === 'screen' && workspace.id === 'email'
|
||||
? { context: 'email' as const }
|
||||
: workspace?.kind === 'dashboard'
|
||||
? { context: 'dashboard' as const, contextId: workspace.key }
|
||||
@@ -184,12 +215,20 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
const [, setPreviewRefresh] = usePreviewRefresh();
|
||||
const [, bumpFilesRefresh] = useFilesRefresh();
|
||||
|
||||
// The caller's handler is read through a ref rather than named as a dependency: a plugin passing an
|
||||
// inline arrow would otherwise rebuild this callback every render, and it goes into EmbeddableChat.
|
||||
const alsoRef = useRef(alsoOnTurnComplete);
|
||||
alsoRef.current = alsoOnTurnComplete;
|
||||
|
||||
const onTurnComplete = useCallback(
|
||||
(hadToolCalls: boolean) => {
|
||||
if (hadToolCalls) {
|
||||
setPreviewRefresh(Date.now());
|
||||
bumpFilesRefresh();
|
||||
}
|
||||
// Outside the `hadToolCalls` guard deliberately — the caller is told about every turn and decides
|
||||
// for itself, because "did anything change" is a question about that screen, not about this one.
|
||||
alsoRef.current?.(hadToolCalls);
|
||||
},
|
||||
[setPreviewRefresh, bumpFilesRefresh],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CirclePlay, ClipboardList, FolderOpen } from 'lucide-react';
|
||||
import { toneText } from '@/components/Data';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ModelOption } from 'state/useModels';
|
||||
import { CHAT_DEFAULT_MODEL } from '../types';
|
||||
import { getProviderDisplayName } from 'state/useModels';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { UsagePopover } from './UsagePopover';
|
||||
@@ -88,7 +89,18 @@ export function ModelSelector({
|
||||
|
||||
const activeProvider = availableModels.find((m) => m.id === displayModel)?.provider ?? providers[0];
|
||||
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
||||
const fallbackModelId = providerModels[0]?.id ?? null;
|
||||
/*
|
||||
The platform's default first, and the catalogue's order only as a last resort.
|
||||
|
||||
`providerModels[0]` is not a default — it is whichever entry happens to be listed first, and the
|
||||
comment below records what that cost once already. It cost it again on 2026-08-25: with the server
|
||||
defaulting to Sonnet, this line selected `fable`, sent it, and the server honoured the choice it was
|
||||
given. A default the client can silently overrule is not a default.
|
||||
|
||||
Falls back to the first entry only when the platform default is not in this provider's list at all —
|
||||
which is every OpenCode model, where there is no Officer-wide default to prefer.
|
||||
*/
|
||||
const fallbackModelId = providerModels.find((m) => m.id === CHAT_DEFAULT_MODEL)?.id ?? providerModels[0]?.id ?? null;
|
||||
|
||||
/*
|
||||
Pick a default only for a chat that has not started.
|
||||
|
||||
@@ -17,6 +17,9 @@ export { AttachmentList } from './components/AttachmentList';
|
||||
export { AttachButton } from './components/AttachButton';
|
||||
export { WebpageDialog } from './components/WebpageDialog';
|
||||
export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
|
||||
// Exported for a plugin that mounts its own chat panel: the registry hands a header only `{ panelId }`,
|
||||
// so binding a chat context means wrapping this rather than passing a prop through the registry.
|
||||
export { ChatHeader } from './ChatHeader';
|
||||
export { useChat, type UseChatType } from '../../hooks/useChat';
|
||||
export { useSlashCommands } from './useSlashCommands';
|
||||
export { useAttachments, type UseAttachmentsType } from './useAttachments';
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* What an unstarted chat offers when nothing else has been chosen.
|
||||
*
|
||||
* **Mirrors `CHAT_DEFAULT_MODEL` in `src/servers/api/chat/websocket.ts` and must not drift from it.** Same
|
||||
* arrangement as `COMMANDER_DEFAULT_MODEL` in `apps/CommandHQ/types.ts`, and for the same reason: the
|
||||
* server decides, but the picker has to show the same answer or it silently sends a different one.
|
||||
*
|
||||
* The composer used to fall back to `providerModels[0]` — the first entry in the catalogue. That is not a
|
||||
* default, it is an ordering accident, and the file already carried a comment about the day `fable` was
|
||||
* added above `opus` and every resumed session came back claiming fable. The same accident then defeated
|
||||
* the server-side default entirely: the client selected fable, SENT fable, and the server honoured what it
|
||||
* was told.
|
||||
*/
|
||||
export const CHAT_DEFAULT_MODEL = 'claude-code/sonnet';
|
||||
|
||||
export type Attachment =
|
||||
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
||||
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean }
|
||||
|
||||
@@ -173,8 +173,11 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
|
||||
useEffect(() => {
|
||||
if (existingSessionId || initialModel) return;
|
||||
const defaultModel = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel;
|
||||
if (defaultModel) setSelectedModel(defaultModel);
|
||||
const stored = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel;
|
||||
// A stored value with no `/` is the bare harness — a routing answer, not a tier. Selecting it would
|
||||
// send it, and the server would then honour a "choice" that means "whatever the CLI picks", i.e. Opus.
|
||||
// Leaving it unselected lets the server apply CHAT_DEFAULT_MODEL, which is the point of that constant.
|
||||
if (stored && stored.includes('/')) setSelectedModel(stored);
|
||||
}, [existingSessionId, initialModel, settings, projectScoped]);
|
||||
|
||||
// Sync selectedModel when initialModel changes (e.g. resuming a session)
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
InputArea,
|
||||
ChatLauncher,
|
||||
ChatPanelWrapper,
|
||||
ChatHeader,
|
||||
AttachmentList,
|
||||
AttachButton,
|
||||
WebpageDialog,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { __testing } from './useAuthoredLayout';
|
||||
|
||||
const { collectSizes, applySizes } = __testing;
|
||||
|
||||
/**
|
||||
* The grafting rules, which are the whole hook.
|
||||
*
|
||||
* Tested at this level deliberately: the React half is `useDashboardState` with a memo around it, and a
|
||||
* test of that would be a test of React Query. What can actually be wrong is the tree walking — and every
|
||||
* way it can be wrong is silent, because a layout with the wrong numbers in it still renders.
|
||||
*/
|
||||
|
||||
const panel = (id: string, appType: string): LayoutNode => ({ type: 'panel', id, appType });
|
||||
|
||||
const group = (id: string, children: { node: LayoutNode; size: number }[]): LayoutNode => ({
|
||||
type: 'group',
|
||||
id,
|
||||
direction: 'horizontal',
|
||||
children,
|
||||
});
|
||||
|
||||
/** The shape that produced the bug: two lists over a chat. */
|
||||
const source = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 62 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 38 },
|
||||
]),
|
||||
size: 66,
|
||||
},
|
||||
{ node: panel('chat', 'proxy-manager-chat'), size: 34 },
|
||||
]);
|
||||
|
||||
describe('collectSizes', () => {
|
||||
test('records every group, nested ones included', () => {
|
||||
expect(collectSizes(source)).toEqual({ root: [66, 34], lists: [62, 38] });
|
||||
});
|
||||
|
||||
test('a lone panel has no sizes at all', () => {
|
||||
expect(collectSizes(panel('only', 'x'))).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySizes', () => {
|
||||
test("the user's sizes win over the author's", () => {
|
||||
const result = applySizes(source, { root: [50, 50], lists: [70, 30] });
|
||||
expect(collectSizes(result)).toEqual({ root: [50, 50], lists: [70, 30] });
|
||||
});
|
||||
|
||||
test("a group with no saved entry keeps the author's numbers", () => {
|
||||
const result = applySizes(source, { lists: [70, 30] });
|
||||
expect(collectSizes(result)).toEqual({ root: [66, 34], lists: [70, 30] });
|
||||
});
|
||||
|
||||
// The case that makes shipping a layout change safe. Two numbers cannot describe three panels, and
|
||||
// applying them would leave the new one with whatever was left over.
|
||||
test("a group whose child count changed keeps the author's numbers", () => {
|
||||
const result = applySizes(source, { root: [10, 20, 70], lists: [70, 30] });
|
||||
expect(collectSizes(result)).toEqual({ root: [66, 34], lists: [70, 30] });
|
||||
});
|
||||
|
||||
test('sizes saved for a group that no longer exists are ignored, not an error', () => {
|
||||
const result = applySizes(source, { 'a-group-from-last-year': [1, 99] });
|
||||
expect(collectSizes(result)).toEqual({ root: [66, 34], lists: [62, 38] });
|
||||
});
|
||||
|
||||
// The whole point: whatever structure comes back from the view — normalised, repaired, rewritten — is
|
||||
// discarded, and only its numbers survive. This is the corruption becoming unrepresentable.
|
||||
test('a repaired structure cannot write itself back', () => {
|
||||
const repaired = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 50 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 50 },
|
||||
]),
|
||||
size: 80,
|
||||
},
|
||||
// What the fallback did: a second copy of the first panel, in the chat's place.
|
||||
{ node: panel('chat', 'proxy-manager-hosts'), size: 20 },
|
||||
]);
|
||||
|
||||
const stored = applySizes(source, collectSizes(repaired));
|
||||
|
||||
// The numbers are kept, because the user may genuinely have dragged them.
|
||||
expect(collectSizes(stored)).toEqual({ root: [80, 20], lists: [50, 50] });
|
||||
// The appType is not, because it was never the user's to change.
|
||||
const chat = (stored as { children: { node: LayoutNode }[] }).children[1]!.node;
|
||||
expect((chat as { appType: string }).appType).toBe('proxy-manager-chat');
|
||||
});
|
||||
|
||||
test('the source is not mutated', () => {
|
||||
const before = JSON.stringify(source);
|
||||
applySizes(source, { root: [1, 99], lists: [1, 99] });
|
||||
expect(JSON.stringify(source)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The loop that broke `/browser`.
|
||||
*
|
||||
* `WorkspaceView` repairs an appType it does not recognise and writes the repair back. With the structure
|
||||
* coming from source, that repair can never persist — so unless an identical write is dropped, it is
|
||||
* recomputed and rewritten every render until React gives up (#185).
|
||||
*/
|
||||
describe('a repair that cannot stick must not be written', () => {
|
||||
test('grafting a repaired structure yields exactly the current value', () => {
|
||||
// What the guard produces: the same tree with a disallowed appType swapped for the fallback.
|
||||
const repaired = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 62 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 38 },
|
||||
]),
|
||||
size: 66,
|
||||
},
|
||||
// `chat` replaced by the fallback — the substitution the allow-list makes every render.
|
||||
{ node: panel('chat', 'proxy-manager-hosts'), size: 34 },
|
||||
]);
|
||||
|
||||
const current = applySizes(source, collectSizes(source));
|
||||
const afterRepair = applySizes(source, collectSizes(repaired));
|
||||
|
||||
// Identical, because only sizes survive and the repair changed none. `setValue` compares these and
|
||||
// returns without writing; if it ever writes, the render loop is back.
|
||||
expect(JSON.stringify(afterRepair)).toBe(JSON.stringify(current));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { LayoutNode, LayoutGroup } from 'officerdev';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDashboardState } from './useDashboardState';
|
||||
|
||||
/**
|
||||
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose SIZES belong to whoever uses it.
|
||||
*
|
||||
* ── The bug this ends ──
|
||||
*
|
||||
* A locked screen persisted its whole layout tree, and that tree is almost entirely the author's work:
|
||||
* `locked` means the user cannot add, move or close a panel. The only thing they can actually change is
|
||||
* how big things are. So the saved blob was a copy of the author's design sitting in the user's slot,
|
||||
* where it could only go stale or get damaged, and it did both:
|
||||
*
|
||||
* - **Stale.** Ship a new `defaultLayout` and nobody who had opened the screen ever saw it. Their copy
|
||||
* won, forever, with no way back that a person could reach.
|
||||
* - **Damaged.** `WorkspaceView`'s `appTypes` guard replaces an unrecognised `appType` with the fallback
|
||||
* and writes the repair back — correct for a user-authored dashboard, catastrophic here. On
|
||||
* 2026-08-25 a plugin layout naming a panel that did not exist yet was rewritten to two copies of its
|
||||
* first panel and SAVED, so the corruption survived every reload, every fix, and every restart. The
|
||||
* row had to be deleted from Postgres, which is not a thing a user can do.
|
||||
*
|
||||
* ── The fix, and why it is a shape rather than a rule ──
|
||||
*
|
||||
* The structure comes from `source` on every render and is never read back from storage. Only sizes are
|
||||
* persisted. So a stale structure cannot exist, a repair has nothing to write itself into, and a plugin
|
||||
* that changes its layout reaches everybody on the next load — with no version to bump, no migration, and
|
||||
* nothing for an author to remember.
|
||||
*
|
||||
* The user keeps the one thing that was theirs.
|
||||
*
|
||||
* ── What is stored ──
|
||||
*
|
||||
* `source` with the user's sizes applied — a valid layout, because the storage validates one on both the
|
||||
* route and a CHECK constraint, and because a value that is always freshly derived from source cannot rot.
|
||||
* Its structure is written and never read. That asymmetry is the whole design and is why it is safe.
|
||||
*
|
||||
* ── Not for unlocked workspaces ──
|
||||
*
|
||||
* A real dashboard is the opposite case: the user genuinely authors the tree, and there the saved
|
||||
* structure IS the thing worth keeping. Use `useDashboardState` directly for those. The line is exactly
|
||||
* where `locked` already falls.
|
||||
*/
|
||||
|
||||
type Sizes = Record<string, number[]>;
|
||||
|
||||
const isGroup = (node: LayoutNode): node is LayoutGroup => node.type === 'group';
|
||||
|
||||
/** Every group's child sizes, by group id. */
|
||||
function collectSizes(node: LayoutNode, into: Sizes = {}): Sizes {
|
||||
if (!isGroup(node)) return into;
|
||||
into[node.id] = node.children.map((child) => child.size);
|
||||
for (const child of node.children) collectSizes(child.node, into);
|
||||
return into;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put saved sizes back onto the author's structure.
|
||||
*
|
||||
* **A group whose child count no longer matches keeps the author's sizes**, which is the case that makes
|
||||
* this safe to ship a layout change into: adding a third panel to a two-panel group means the old pair of
|
||||
* numbers describes a layout that no longer exists, and applying them would leave the new panel at
|
||||
* whatever was left over. Dropping them silently is right — the author's proportions are a better guess
|
||||
* than arithmetic on a stale pair.
|
||||
*/
|
||||
function applySizes(node: LayoutNode, sizes: Sizes): LayoutNode {
|
||||
if (!isGroup(node)) return node;
|
||||
|
||||
const saved = sizes[node.id];
|
||||
const usable = saved?.length === node.children.length ? saved : null;
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map((child, index) => ({
|
||||
node: applySizes(child.node, sizes),
|
||||
size: usable ? usable[index]! : child.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same shape as `useDashboardState`, so a screen swaps one call and changes nothing else.
|
||||
*
|
||||
* `setValue` accepts a whole layout because that is what `WorkspaceView` reports — a resize hands back the
|
||||
* entire tree. Everything but its sizes is discarded here, which is precisely the point: the view is free
|
||||
* to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
|
||||
*/
|
||||
export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
const stored = useDashboardState<LayoutNode>(key, source);
|
||||
|
||||
const value = useMemo(() => applySizes(source, collectSizes(stored.value)), [source, stored.value]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => {
|
||||
const next = typeof update === 'function' ? update(value) : update;
|
||||
const merged = applySizes(source, collectSizes(next));
|
||||
|
||||
/*
|
||||
A write that changes nothing is dropped, and this is not an optimisation — it is what stops an
|
||||
infinite render loop.
|
||||
|
||||
`WorkspaceView`'s `appTypes` guard repairs an unrecognised appType and WRITES THE REPAIR BACK. That
|
||||
was self-limiting while the structure was persisted: the repair stuck and the next render agreed
|
||||
with it. Here the structure always comes from `source`, so the repair can never stick — it is
|
||||
recomputed, rewritten, discarded and recomputed, every render, forever. React gives up with
|
||||
"maximum update depth exceeded" (#185) and the screen fails to render.
|
||||
|
||||
Not hypothetical: the `browser` plugin's layout names `officerdev/chat`, which is not one of its own
|
||||
panel keys, so every render of `/browser` produced a repair. Shipping the sizes-only hook turned a
|
||||
silent, harmless fallback into a hard crash on that screen.
|
||||
|
||||
Comparing the serialised value is enough — these trees are small, this runs only on a real change,
|
||||
and it is the same check `setValue` would otherwise force React Query to make against a new object.
|
||||
*/
|
||||
if (JSON.stringify(merged) === JSON.stringify(value)) return;
|
||||
stored.setValue(merged);
|
||||
},
|
||||
// `stored.setValue` is stable per key; `value` is memoised above.
|
||||
[source, value, stored],
|
||||
);
|
||||
|
||||
return { key: stored.key, value, setValue, isLoaded: stored.isLoaded, loadFailed: stored.loadFailed };
|
||||
}
|
||||
|
||||
/** The tree walking, exposed for its test. Not part of the hook's API — import the hook. */
|
||||
export const __testing = { collectSizes, applySizes };
|
||||
@@ -119,6 +119,18 @@ export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string;
|
||||
// Query string for the selected working directory (null/undefined = the caller's own home).
|
||||
const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : '');
|
||||
|
||||
/**
|
||||
* A chat context, for a caller that cannot name its own directory.
|
||||
*
|
||||
* A plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, which the server builds and never
|
||||
* sends to the browser — so the `?cwd=` form has nothing to put in it. Naming the context instead lets the
|
||||
* server resolve the same path it resolves for the turn. See `cwdOf` in `api/chat/chat.ts`.
|
||||
*/
|
||||
export type ChatContext = { context: string; contextId?: string };
|
||||
|
||||
const contextQuery = (ctx: ChatContext) =>
|
||||
`?context=${encodeURIComponent(ctx.context)}${ctx.contextId ? `&contextId=${encodeURIComponent(ctx.contextId)}` : ''}`;
|
||||
|
||||
/** The default /chat dir plus every directory that already has Claude sessions. */
|
||||
export function useChatPwds() {
|
||||
const client = useClient();
|
||||
@@ -143,16 +155,25 @@ export function useChatPwds() {
|
||||
* ZERO requests while a node is collapsed — twenty directories on screen is otherwise twenty transcript
|
||||
* scans for rows nobody has asked to see.
|
||||
*/
|
||||
export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean; enabled?: boolean }) {
|
||||
export function useClaudeSessions(
|
||||
cwd?: string | null,
|
||||
options?: { all?: boolean; enabled?: boolean; chatContext?: ChatContext | null },
|
||||
) {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const all = options?.all === true;
|
||||
const chatContext = options?.chatContext ?? null;
|
||||
// `all` ignores cwd entirely — sending both would imply the server intersects them, and it does not.
|
||||
const q = all ? '?all=1' : cwdQuery(cwd);
|
||||
// A context likewise replaces cwd rather than joining it: the server resolves one directory from it.
|
||||
const q = all ? '?all=1' : chatContext ? contextQuery(chatContext) : cwdQuery(cwd);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
||||
queryKey: [SESSIONS_KEY, all ? '__all__' : (cwd ?? 'default')],
|
||||
// The context belongs in the key, or two panels on different plugins share one cached list.
|
||||
queryKey: [
|
||||
SESSIONS_KEY,
|
||||
all ? '__all__' : chatContext ? `ctx:${chatContext.context}:${chatContext.contextId ?? ''}` : (cwd ?? 'default'),
|
||||
],
|
||||
enabled: isAuthenticated && options?.enabled !== false,
|
||||
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
|
||||
staleTime: 30 * 1000,
|
||||
|
||||
@@ -9,7 +9,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+↑/↓ 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
|
||||
@@ -21,7 +21,9 @@ 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 `f` flips the favourites-only filter. It used to star what was focused, but starring
|
||||
* already has a chord (`ctrl+shift+f`, and `l` applies the focused theme first when it is not the one
|
||||
* worn), so the single letter goes to the thing that had none.
|
||||
*
|
||||
* 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.
|
||||
@@ -36,6 +38,12 @@ import type { ThemeId } from './themes';
|
||||
* The starting theme is captured when the list opens, so `h` and `Escape` can undo an entire browse
|
||||
* regardless of how many previews happened in between.
|
||||
*
|
||||
* Browsing walks `browseOrder` — the list as DISPLAYED — not the registry. The two orders must be the
|
||||
* same list or the focus visibly jumps, which is exactly what happened when the overlay lifted
|
||||
* favourites to a top block while stepping walked registry order. The overlay now shows one plain
|
||||
* rotation and `f` flips a favourites-only filter (`onToggleFilter`); either way, what you see is
|
||||
* what `j`/`k` walk.
|
||||
*
|
||||
* ── Not a UI ──
|
||||
*
|
||||
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
|
||||
@@ -43,12 +51,19 @@ import type { ThemeId } from './themes';
|
||||
*/
|
||||
type ThemeKeysOptions = {
|
||||
/**
|
||||
* Star or unstar the focused theme — bound to `f` while the list is open.
|
||||
* Star or unstar the CURRENT theme — bound to `ctrl+shift+f`, from anywhere.
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
* The ids `j`/`k` traverse while the list is open, in DISPLAY order — see the note above. Absent, the
|
||||
* browse falls back to the full registry rotation.
|
||||
*/
|
||||
browseOrder?: readonly ThemeId[];
|
||||
/** Flip the favourites-only filter — bound to `f` while the list is open. App state, like the stars. */
|
||||
onToggleFilter?: () => void;
|
||||
};
|
||||
|
||||
export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
@@ -57,6 +72,10 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
// on every render, since it closes over a mutation.
|
||||
const onToggleFavorite = useRef(options.onToggleFavorite);
|
||||
onToggleFavorite.current = options.onToggleFavorite;
|
||||
const browseOrder = useRef(options.browseOrder);
|
||||
browseOrder.current = options.browseOrder;
|
||||
const onToggleFilter = useRef(options.onToggleFilter);
|
||||
onToggleFilter.current = options.onToggleFilter;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState<ThemeId>(themeId);
|
||||
const startedOn = useRef<ThemeId>(themeId);
|
||||
@@ -74,22 +93,40 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
[themes],
|
||||
);
|
||||
|
||||
// The in-list step. Walks `browseOrder` when given; a focus that is not IN that order — the current
|
||||
// theme is a favourite, or the mouse hovered one — enters the rotation at its nearest end rather than
|
||||
// wrapping from an index that does not exist.
|
||||
const browseStep = useCallback(
|
||||
(from: ThemeId, delta: number): ThemeId => {
|
||||
const ids = browseOrder.current;
|
||||
if (!ids || ids.length === 0) return step(from, delta);
|
||||
const at = ids.indexOf(from);
|
||||
if (at === -1) return delta > 0 ? ids[0]! : ids[ids.length - 1]!;
|
||||
return ids[(at + delta + ids.length) % ids.length]!;
|
||||
},
|
||||
[step],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const { themeId: current, focused: focus, open: isOpen } = state.current;
|
||||
|
||||
// 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') {
|
||||
// Toggle the list. Vertical arrows on the SAME modifier as the horizontal cycle — one family:
|
||||
// `ctrl+shift+←/→` steps the theme, `ctrl+shift+↑/↓` opens the browse. Was `ctrl+shift+space`
|
||||
// (with a `ctrl+alt+space` legacy) until 2026-08-25; both space chords are gone, not kept as
|
||||
// aliases, because `ctrl+space` became Spotlight the same day and space chords near it invite
|
||||
// the wrong reflex.
|
||||
if (event.ctrlKey && event.shiftKey && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
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.
|
||||
DropdownMenuTrigger, and a focused trigger opens on ArrowDown (it did on Space too, when Space
|
||||
was the chord) — 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.
|
||||
@@ -152,21 +189,21 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
switch (event.key) {
|
||||
case 'f':
|
||||
case 'F':
|
||||
// Star or unstar what is focused, without moving or applying it.
|
||||
// Favourites only ↔ everything. Starring is `ctrl+shift+f`, handled above before this switch.
|
||||
event.preventDefault();
|
||||
onToggleFavorite.current?.(focus);
|
||||
onToggleFilter.current?.();
|
||||
return;
|
||||
case 'j':
|
||||
case 'J':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setFocused((prev) => step(prev, 1));
|
||||
setFocused((prev) => browseStep(prev, 1));
|
||||
return;
|
||||
case 'k':
|
||||
case 'K':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setFocused((prev) => step(prev, -1));
|
||||
setFocused((prev) => browseStep(prev, -1));
|
||||
return;
|
||||
case 'l':
|
||||
case 'L':
|
||||
@@ -200,7 +237,7 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
// the dropdown trigger opening alongside the overlay.
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [setTheme, step]);
|
||||
}, [setTheme, step, browseStep]);
|
||||
|
||||
return { open, setOpen, focused, setFocused };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot
|
||||
title: "A commander's watcher does not survive a reboot, and nothing says so"
|
||||
theme: agent-coordination
|
||||
status: open
|
||||
priority: urgent
|
||||
created: 2026-08-25
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**A commander's watcher loop is a bare background process, so a reboot ends it and nothing brings it
|
||||
back or mentions it.** Found 2026-08-25 while trying to deploy `2026.08.25.01`: the tag was pushed and
|
||||
nothing picked it up.
|
||||
|
||||
## What was verified
|
||||
|
||||
- `commanders/40/state/loop-pid` held **291464, dead**. Its last state file was written 2026-08-24
|
||||
03:50 — the `2026.08.24.02` rollout.
|
||||
- `uptime -s` says the machine came up **2026-08-24 12:40:11**.
|
||||
- `commanders/54` (Voice Memos) was armed after that boot and its pid is alive, which is why one card
|
||||
reads "watching" and the other does not.
|
||||
- Nothing else was watching either: no cron entry, and `data/deploy-watch.sh` was not running.
|
||||
|
||||
So a release sat on the remote with nobody looking at it, and the only signal was the absence of a word
|
||||
on a card. `watcher_armed` in `command_hq_sessions` was already `false` for both rows, so even the
|
||||
database did not think it was armed — the UI is honest, it just says nothing.
|
||||
|
||||
## Why it is worse than an ordinary crash
|
||||
|
||||
The commander doctrine is that you arm it and walk away for months. A watcher that dies on reboot
|
||||
breaks exactly the promise that makes a commander worth having, and it breaks it **quietly** — no
|
||||
notification, no failed run, no error anywhere. The owner discovers it by noticing that something which
|
||||
should have happened did not, which is the failure mode `docs/agent-coordination.md` exists to avoid.
|
||||
|
||||
Note it is not only reboots. Anything that kills the process tree the loop was spawned under does the
|
||||
same thing, and the loop's parentage is an accident of which agent turn armed it.
|
||||
|
||||
## What to build, in order of how much it buys
|
||||
|
||||
1. **Say it out loud.** The cheapest fix and most of the value: a commander whose row says it was armed
|
||||
and whose pid is dead is a **detectable** state — `liveLoopPid()` already answers it, and
|
||||
`router.ts` already computes `watcherArmed` and `watcherRunning` separately for exactly this reason.
|
||||
Notify the owner once when a watcher that was armed is found not running. Nothing else on this list
|
||||
matters if the owner is told.
|
||||
|
||||
2. **Bring it back on boot.** A loop that records its pid can record its command line beside it, and
|
||||
something at platform start can re-arm every commander whose row says armed and whose pid is gone.
|
||||
The awkward part is that the loop is authored by the commander itself, per commander — so what gets
|
||||
restarted has to be recorded when it is armed, not reconstructed afterwards.
|
||||
|
||||
3. **Stop it being a bare process at all.** `docs/commander-directory.md` already argues a commander is
|
||||
a directory rather than a session. A watcher belongs to that directory and could be supervised the
|
||||
way sidecars are, rather than inheriting whichever agent turn happened to spawn it.
|
||||
|
||||
## Interim, until any of it exists
|
||||
|
||||
**After a reboot, re-arm every commander by hand.** `watcherRunning` on the Command HQ grid is the
|
||||
truthful field — a card that does not say "watching" is not watching, whatever it did yesterday.
|
||||
|
||||
Related: [[agent-coordination]] docs, and `data/deploy-watch.sh`, which is the deploy commander's own
|
||||
loop script and equally unsupervised.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
theme: docs-conventions
|
||||
heading: 'Documentation conventions — AGENTS.md vs CLAUDE.md'
|
||||
kind: theme
|
||||
---
|
||||
|
||||
Which file is the real instructions and which is the pointer, kept consistent across the platform repo,
|
||||
its subdirectories, and every plugin repo.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
id: docs-conventions/sweep-agents-md-primary-claude-md-pointer
|
||||
title: 'Sweep: AGENTS.md becomes the real instructions everywhere, CLAUDE.md a pointer beside it'
|
||||
theme: docs-conventions
|
||||
status: open
|
||||
created: 2026-08-25
|
||||
date_source: stated
|
||||
order: 1
|
||||
---
|
||||
|
||||
**AGENTS.md becomes the main instruction file everywhere, with a CLAUDE.md beside it that just
|
||||
references it — not the other way round, which is how it is today.** Wanted 2026-08-25. A full sweep
|
||||
across the platform and every plugin repo.
|
||||
|
||||
**The root pairing exists already, backwards.** `platform/AGENTS.md` is five lines: _"The instructions
|
||||
live in `CLAUDE.md`. Read it first — this file only points there."_ `CLAUDE.md` is the real, long
|
||||
document. That is the exact inverse of what is wanted, and it is the clearest single example of the
|
||||
sweep: swap which file holds the content and which file points, keep both present so either harness's
|
||||
auto-discovery convention finds the same guidance — which is the reasoning the current file already
|
||||
states, just applied backwards.
|
||||
|
||||
**Four subdirectory `CLAUDE.md` files carry real, substantial documentation and have no `AGENTS.md` at
|
||||
all** — found 2026-08-25, none of these have a pair to flip, they need one created:
|
||||
`platform/src/apps/CLAUDE.md` (271 lines), `platform/src/databases/CLAUDE.md` (231 lines),
|
||||
`platform/themes/CLAUDE.md` (118 lines), `platform/plugins/CLAUDE.md` (92 lines).
|
||||
|
||||
**Every plugin repo has NEITHER file — 19 of 19, checked 2026-08-25.** `atelier`, `bitcoin-wallet`,
|
||||
`browser`, `desktop`, `email`, `example`, `gitea`, `invoices`, `iris`, `jellyfin`, `kiosk`,
|
||||
`minesweeper`, `music`, `offscale`, `photos`, `proxy-manager`, `soulseek`, `transcribe`, `transmission`,
|
||||
`vault` — none has a `CLAUDE.md` or an `AGENTS.md`. This is not a swap for plugins, it is adding the
|
||||
convention where it does not exist yet. **`PLUGIN.md`, present in most of them, is a different document
|
||||
and stays untouched** — it is the plugin's own manifest/reference doc (conventions, API surface), not
|
||||
agent guidance; do not conflate the two when sweeping.
|
||||
|
||||
**Two lookalikes are NOT documentation and are out of scope for this sweep** — converting them would be
|
||||
wrong, not just unnecessary:
|
||||
|
||||
- **Per-commander `CLAUDE.md` files** (`data/pastilhas@officer.dev/commanders/<id>/CLAUDE.md`) are a
|
||||
single commander's own standing brief — operational state for one running agent, not source-tree
|
||||
documentation nobody discovers it by directory convention the way a coding CLI does at a repo root.
|
||||
- **`plugins/proxy-manager/chat/CLAUDE.md`** carries its own warning: _"rewritten from the plugin on
|
||||
every chat that starts here, so an edit would be lost."_ Generated per session, not read by directory
|
||||
convention either — injected directly into that chat's context by the plugin's own code.
|
||||
|
||||
**Note `atelier-work/` is a separate checkout carrying its own copies of the root, `themes/`,
|
||||
`plugins/`, `src/apps/` and `src/databases/` `CLAUDE.md`/`AGENTS.md` files** — apparently a working
|
||||
clone related to the Atelier extraction. Worth checking whether it is still live before sweeping it
|
||||
twice; if it is a stale branch, the sweep only needs to happen once, in the platform repo.
|
||||
|
||||
---
|
||||
|
||||
**2026-08-25 — the owner points at prior art: `docs/claude-md-touchpoints.md`, an audit written the
|
||||
same day, before any change, exactly so this sweep would not be guessed at.** It replaces the "two
|
||||
lookalikes" section above with something precise: **three writers, one reader**, all named by file and
|
||||
line —
|
||||
|
||||
1. `command-hq/directory.ts:653`, `createCommanderDirectory()` — generates a commander's `CLAUDE.md`
|
||||
ONCE, at creation, from `commanderClaudeMd()`. Never rewritten afterward; the commander extends it
|
||||
itself over its own life, which is why this file is correctly excluded from any flip — there is no
|
||||
"primary content" to move, because the content is written by the commander, not shipped as
|
||||
documentation.
|
||||
2. `api/chat/websocket.ts:237`, `installPluginChatBrief()` — copies a plugin's own `chat/CLAUDE.md`
|
||||
into a plugin-chat's directory, content-diffed so it only rewrites on an actual change. Of 19
|
||||
plugins, exactly one (`proxy-manager`) ships one.
|
||||
3. `scripts/backfill-commander-credentials.ts` — a manual, idempotent append pass over EXISTING
|
||||
commander files, marker-guarded so it never duplicates a section.
|
||||
4. `scripts/docs-check.ts` — read-only, checks `platform/CLAUDE.md` and `officer-dev/CLAUDE.md` (not a
|
||||
plugin's, not a commander's) for numeric drift. This is the one file this sweep must not touch
|
||||
carelessly: whatever ends up as the "pointer" file still has to carry the two numbers `docs:check`
|
||||
parses out of it, or the check needs to move to reading `AGENTS.md` instead.
|
||||
|
||||
**Confirms the exclusion already written above, with better grounding — do not re-derive it, cite the
|
||||
audit instead.** Commander files and plugin chat briefs are runtime-authored content injected into an
|
||||
agent's cwd, not source-tree documentation a coding CLI discovers by walking up from where it starts.
|
||||
Flipping "which file is primary" has no meaning for a file with no counterpart and no static content to
|
||||
begin with.
|
||||
|
||||
**Two things the audit surfaces that are genuinely new and unresolved, not covered by "flip the pointer
|
||||
everywhere":**
|
||||
|
||||
- **The owner's personal `~/.claude/CLAUDE.md` is explicitly, deliberately, never touched by any of
|
||||
this** — the audit's own "Explicitly not written" section: the owner's sessions are un-isolated, `HOME`
|
||||
points at their real home, and a generated file there would land inside the hand-maintained document
|
||||
the owner edits directly (and that a terminal `claude` also reads). **This file is out of scope for
|
||||
the sweep entirely** — it is not a repository doc, it is the owner's own, and it is inherently
|
||||
Claude-specific by its path (`~/.claude/`), not something an `AGENTS.md` convention could apply to.
|
||||
- **A commander's dynamically-generated file is named `CLAUDE.md` specifically, and commanders can run
|
||||
under opencode.** The audit does not ask whether opencode discovers a `CLAUDE.md` in its working
|
||||
directory the way Claude Code does — only Claude/opencode's actual read-side behaviour answers that,
|
||||
and it was out of scope for an audit about WRITING. If opencode does not read `CLAUDE.md` by
|
||||
convention, an opencode-run commander is silently briefed with nothing, which would be a real gap
|
||||
this sweep should catch rather than one it assumes away. Check before deciding whether
|
||||
`commanderClaudeMd()`'s output needs an `AGENTS.md`-named counterpart too.
|
||||
Reference in New Issue
Block a user