Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 121a77ce7f release 2026.08.26.02
one fix, and it is a crash: /browser fails to render with react #185 on every
host running .01 with the browser plugin installed, which is the fleet.

useAuthoredLayout takes structure from source every render, which is right and
is what lets a plugin's layout change reach people who already opened it. what
it did not account for is WorkspaceView repairing an unrecognised appType and
WRITING THE REPAIR BACK — self-limiting when structure was persisted, an
infinite loop when it cannot stick.

fofos is already at the fix by hand, mid-work. the plan says so rather than
leaving the commander to read it as drift.

no schema change, no dependency change, and officer-claude-code stays untouched
again — nothing here needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 04:31:51 +00:00
pastilhasandClaude Opus 5 a7c9dab22c a repair that cannot stick must not be written
useAuthoredLayout turned a harmless fallback into a hard crash, and /browser on
fofos is where it showed: "This screen failed to render", react #185, maximum
update depth exceeded.

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. with the structure coming from
source, it can never stick: recomputed, rewritten, discarded, 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 every single render of that screen produced a repair. the same
trap proxy-manager hit last night, except that one only rendered the wrong
panel — this one loops.

so a write whose result equals the current value is dropped. not an
optimisation: it is the thing that terminates the loop. persisting an identical
tree was pointless anyway.

tested by grafting a repaired structure and asserting it comes back identical
to the current value — which is the exact condition setValue now checks, so if
it ever writes there, the loop is back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 04:17:30 +00:00
pastilhasandClaude Opus 5 5005726994 deploy report 2026.08.26.01
5 of 5 target hosts. alpha, fofos, production-pertento-bridge,
deadpool-rezio-bridge, macbookpro all at e9f1760f.

officer-dev and edge-pertento-bridge skipped by plan. officer-claude-code
not restarted anywhere, so the widened chat credential is not active yet.

Two findings: db:push wants the same ALTER on command_hq_sessions on every
host, forever, and docs/servers.md contradicts the plan about
edge-pertento-bridge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 03:51:55 +00:00
pastilhasandClaude Opus 5 e9f1760f27 release 2026.08.26.01
22 commits. no schema change, no dependency change.

the reason it is being cut tonight rather than tomorrow: officerdev gained an
export that a SHIPPED plugin already imports. proxy-manager is in the catalogue
now and its ChatPanel imports ChatHeader, which exists only from 2ed47fa6. on a
host running 2026.08.25.01 the plugin installs, the frontend rebuild fails with
"No matching export for import ChatHeader", and the SPA keeps its previous
bundle — so the plugin looks installed and its route redirects to home. that
happened on fofos tonight and is what this release fixes.

the plan says explicitly not to restart officer-claude-code: the widened chat
credential lives there rather than in officer, and bouncing it ends every live
agent session including the one running the rollout. pending is the honest
report, not something to take.

edge stays excluded, unchanged and for unchanged reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 03:37:40 +00:00
pastilhasandClaude Opus 5 a2102c5b46 "generating" is not "has an outstanding background task"
the guard I added yesterday to stop the reviver interrupting a commander
mid-work disabled the feature entirely, and it took a real case to see it.

it asked collectRunningTasks, which counts BACKGROUND TASKS. a commander's
watcher IS a background task, and it 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 guard read "busy" forever,
and the reviver could never wake the one thing it exists for.

found on the deploy commander tonight: loop dead, pid file saying so, and the
task "Platform Deployer watcher — polls origin for deploy/* tags" still marked
running from its own revival six hours earlier. permanently true.

this is the shape I criticised in the nudger yesterday — a guard that could
only ever be right by accident — written by me a few hours later. the right
question was always whether the AGENT is mid-turn, which the sidecar answers
directly and which the nudger was already asking before I removed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 03:36:39 +00:00
pastilhasandClaude Fable 5 a7b95a7bd1 todo: fold the existing claude-md-touchpoints audit into the AGENTS.md sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:26:05 +00:00
pastilhasandClaude Fable 5 9f2b703b4b todo: sweep AGENTS.md/CLAUDE.md so AGENTS.md is primary everywhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:24:17 +00:00
pastilhasandClaude Opus 5 9a3b5dd649 a screen can hear when a chat turn finished
ChatPanelWrapper already refreshed the file tree and the preview when a turn
ran tools. it kept that to itself, so a screen whose own panels show something
a turn can change had no way to know — which is every plugin with a chat beside
a list.

so onTurnComplete is a prop now, composed with the platform's own handling
rather than replacing it. called for EVERY turn, outside the hadToolCalls
guard, because "did anything I care about change" is a question about the
caller's screen and not about this one — it gets the flag and decides.

read through a ref: a caller passing an inline arrow would otherwise rebuild
the callback every render, and it goes into EmbeddableChat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:03:34 +00:00
pastilhas f9f4753425 audit: every place the platform writes or touches CLAUDE.md 2026-08-25 19:00:54 +00:00
pastilhasandClaude Opus 5 8a12d5094b a plugin briefs its chats with a file, not a prefix
a plugin said what its chats should know through promptPrefix, which rides
every message. three costs, and the owner hit all of them: paid per turn
forever, invisible as a design artefact because it lives in a typescript
constant, and visible in the one place it should not be — he sent three words
and watched a paragraph of preamble go out attached to them.

so the platform now copies plugins/<name>/chat/CLAUDE.md into the chats
directory whenever a chat starts there, and claude code reads it from its own
cwd at session start. same words, no per-turn cost, and a file the person it is
about can open and edit.

COPIED, which looks like it contradicts docs/plugin-capabilities.md. it does
not: 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 indirection changes where that program looks. the staleness is
answered by rewriting on every resolve rather than once at install, so the file
is always this version of the plugin's and plugins:update needs no republish
step. skipped when the content already matches, so mtimes do not churn.

best effort: no brief is the ordinary case, and a chat that cannot be given one
still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:50:01 +00:00
pastilhasandClaude Opus 5 4adf96efa4 the picker stopped overruling the default
a new chat offered fable. not displayed it — SELECTED it, sent it, and the
server honoured the choice it was given. the composer's fallback was
providerModels[0], the first entry in the catalogue, which is not a default but
an ordering accident. 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 quietly defeated the whole server-side default.

so the fallback prefers the platform's default and uses catalogue order only
when that model is not in the provider's list at all — which is every opencode
model, where there is no officer-wide default to prefer.

and a test, because a comment cannot hold a constant mirrored across two
packages. it asserts the client's copy equals the server's, and that the value
names a tier rather than the bare harness. both of the ways this default has
been defeated are now failures a test catches: a drifted mirror, and a stored
harness read as a choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:33:38 +00:00
pastilhasandClaude Opus 5 2ed47fa65e a screen can declare its own chat context
ChatPanelWrapper's own comment asked for this: "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." this is the general fix rather
than the second id.

a caller passes chatContext; the workspace-derived branch stays for the screens
the platform mounts, including the hardcoded 'email' one, which is not a caller
that could pass anything.

the listing needed the same. ChatHeader derives an absolute cwd by joining the
workspace's home-relative path onto the account home — right for a dashboard,
impossible for a plugin, whose directory the server builds and never sends. so
useClaudeSessions takes a context instead and lets the server resolve the same
path it resolved for the turn. it goes in the query key too, or two plugins
share one cached list.

ChatHeader is exported now, because a registry header is mounted with only
{ panelId } — binding a context means wrapping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:30:54 +00:00
pastilhasandClaude Opus 5 367006fb5d a plugin can ask for its own chats back
a plugin's chats run in $DATA_PATH/<email>/plugins/<appName>/chats, built
server-side and deliberately never sent to the browser. which left the plugin
able to START conversations in its own directory and with no way to LIST them —
the path is the one thing it cannot name.

so /chat/sessions takes ?context=plugin&contextId=<appName> and resolves it the
same way the turn does. the four call sites already shared cwdOf; it takes the
identity now rather than just the home.

the plain ?cwd= form stays for /chat, where the user picks a directory and
knows what they picked. it is not a hole either way: listClaudeSessions reads
that identity's own ~/.claude/projects, so naming another directory shows you
your own conversations elsewhere, never anybody else's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:25:25 +00:00
pastilhasandClaude Opus 5 cb41a8fe16 a plugin's chats get a directory of their own
$DATA_PATH/<email>/plugins/<appName>/chats, resolved server-side from a
context the client names.

why a directory: claude code reads a CLAUDE.md from its cwd at session start,
which is the entire mechanism behind docs/commander-directory.md. a plugin that
wants its chats to know something specific writes it there once instead of
paying for it in every preamble on the machine. it also keeps those chats off
the account's home, where they were sitting on top of everything its owner has.

the client sends context='plugin' and contextId=<appName> and NOT a path.
commander-directory.md reversed exactly that call and the reasoning carries: a
client that can name a directory can name any directory. getPluginChatsDir
refuses a separator, same guard as getCommanderDir.

and the part that would have broken quietly: confineUserTree chmods every
sibling of home to 0700 owned by the service user, carving out only
`commanders`. `plugins` now joins it and takes the same member ownership.
without that this is the general_chat_sessions failure a third time — a
member's turn dying on its own working directory before running anything, which
reads as the agent being broken rather than as a permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:18:11 +00:00
pastilhasandClaude Opus 5 0ef1bbc3c4 a stored bare harness is not a model choice
CHAT_DEFAULT_MODEL appeared to do nothing, and the picker still read
"claude-code" after a refresh. both because an account can have the BARE
harness stored as its preference, and this one does: chat.defaultProjectModel
and tasks.defaultModel are each 'claude-code'. the client selected it, sent it,
and the server honoured a choice that means "whatever the CLI picks" — opus.
the default only ever applied when nothing was stored, which was nobody.

`claude-code` with no tier is a routing answer and a legitimate one — it is
what decideResume assumes. as an answer to "which model", it is the absence of
one. so it is now READ as unset, in all three places that ask: the chat's
model resolution, the task resolver, and the client's initial selection.

nobody's settings are rewritten. the row keeps what it says and "no tier
chosen" now means what it always meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:09:01 +00:00
pastilhasandClaude Opus 5 355fcaf1e5 a chat's key follows its holder, minus administration
it was ['notify'], on the argument 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 true. the second is what undid the first.

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 scoping its key to notify never made it safer — it made it unable to use the
platform POLITELY while every impolite route stayed open. that became visible
today: 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 from the same
container.

a route through the platform is authorised, logged and bounded by a permission.
one around it is none of those. withholding the first does not prevent the
second, it only chooses which gets used.

so: the holder's own authority, minus admin. a RULE against the live registry,
not a list — a plugin installed next year works from a chat without this file
changing. the alternative on the table was adding each plugin's key to an array
here, which puts plugin names in core and hands every plugin to every chat, one
line at a time, forever.

execution needs no exclusion; authorize.ts strips it whatever a row says. admin
is the one worth naming: an agent may use the server and may not administer it.
accounts, grants and plugin installs stay with a person.

and it is NARROWER for a member, which is where it counts — their agent runs as
their own linux user with no owner environment, so this key is a real boundary
resolving to exactly what their role was granted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 18:04:38 +00:00
pastilhasandClaude Opus 5 42d9068f7b sonnet is what runs when nobody chose
every surface with no model picker was quietly running opus. the fallback was
the bare harness, `claude-code`, and bare means the CLI picks its own tier —
agent-files.ts says so in as many words. so a plugin's chat panel, a nudge, a
task with no configured default, any chat opened where there is nowhere to
choose: all opus, for work that rarely needs it. the commander pinned to sonnet
on 2026-08-25 after spending ~$0.26 of opus on one routing decision was the
same problem noticed in one place.

DEFAULT_MODEL could not simply be changed, because it is two questions wearing
one constant. decideResume uses it as a HARNESS IDENTITY when neither side
knows which sidecar a resuming session belongs to, and that comparison is
between harnesses — a tier suffix there would be comparing something else, and
the three failures its comment records are all delivery failures. so it stays
bare and keeps its one job, now stated.

CHAT_DEFAULT_MODEL is the other question, answered separately. a floor, not a
ceiling: a client that names a model still wins, an account default still wins,
and only "nobody chose" changes. tasks get the same treatment for the same
reason and a stronger one — a task runs unattended, often repeatedly, with
nobody watching the bill.

the nudger held a copy of the string; it holds the constant now.

interim on purpose. per-surface defaults, or something that reads the work
rather than the caller, is the better answer and is not this. one constant, one
place, easy to move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 17:51:24 +00:00
pastilhasandClaude Opus 5 a7b61a07eb the structure is the author's, the sizes are the user's
a locked screen persisted its whole layout tree, and `locked` means the user
cannot add, move or close a panel — so the only thing in that blob they could
actually change was how big things are. everything else was a copy of the
author's design sitting in the user's slot, where it could only go stale or get
damaged. 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 a person could reach.

DAMAGED: WorkspaceView's appTypes guard replaces an unrecognised appType with
the fallback and writes the repair back — right for a user-authored dashboard,
catastrophic here. today a plugin layout naming a panel that did not exist yet
was rewritten to two copies of its first panel and SAVED. it survived every
reload and every fix, and the row had to be deleted from postgres. twice,
because an open tab wrote its in-memory copy back over the first delete.

useAuthoredLayout takes the structure from source on every render and persists
only sizes. a stale structure cannot exist, a repair has nothing to write
itself into, and a layout change reaches everybody on the next load — no
version to bump, no migration, nothing for an author to remember. the
corruption stops being repaired and becomes unrepresentable.

a group whose child count no longer matches keeps the author's numbers: two
sizes cannot describe three panels, and applying them anyway would leave the
new one with the remainder. that guard is what makes shipping a layout change
safe rather than merely possible.

what is stored is source-with-your-sizes, a valid layout because the route and
a CHECK constraint both demand one — written and never read as structure. the
asymmetry is the design.

PluginScreen only, for now. every core screen is locked and has the same bug;
that is a mechanical sweep of one call each and wants its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 17:47:18 +00:00
pastilhas d3deca0d08 theme overlay keeps its size across the favourites filter, f flips the filter 2026-08-25 17:10:47 +00:00
pastilhas d0f83b58c9 theme overlay is one plain rotation, favourites in place, tab toggles a favourites filter 2026-08-25 17:06:46 +00:00
pastilhas 5f803c3fb0 theme browse steps the displayed list, favourites block out of the rotation 2026-08-25 16:58:32 +00:00
pastilhas 4efca226f6 spotlight on ctrl+space, theme browser on ctrl+shift+up/down, no backdrop behind the theme list 2026-08-25 16:53:29 +00:00
pastilhas 4a6e4a773c deploy 2026.08.25.01: 5 of 5 targets, schema verified by query on each 2026-08-25 16:41:15 +00:00
pastilhasandClaude Opus 5 ac25cd4860 do not wake a commander that is working, and never more than three times
two guards the first cut was missing, both about the same thing: a dead pid is
not a dead watch.

a watcher EXITS when it fires — that is the mechanism, since a background
process reaches the model only by ending. so the healthy path is poll, find,
exit, hand back, act, re-arm, and for the whole middle of that the pid file
names a process that is gone. ticking into that window would have interrupted
a commander mid-work to tell it its watch had died. so: if a turn is running,
the exit is already being handled and the re-arm is the last thing that turn
will do.

and a bound, for the case the first guard cannot see. a commander that
genuinely cannot re-arm would otherwise be woken every two minutes forever,
each wake a real agent turn — the same unbounded retry
prompts/deploy-commander.md records the first deploy watcher dying of. three
wakes in six hours, then leave it alone; a live loop resets the count. an
unreadable attempts file counts as tried, because failing towards fewer wakes
is the cheap direction.

the near miss that prompted this is recorded honestly in the file: a pid read
as dead two seconds after being written and the process was in fact alive and
mid-claim. the lesson is that a pid file is a poor instant answer to "is this
watching", which is enough to justify both guards without claiming more than
was seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 16:36:05 +00:00
pastilhasandClaude Opus 5 e1f5d01b90 a dead watch wakes its commander instead of staying dead
the machine rebooted 2026-08-24 at 12:40 and took the deploy commander's loop
with it. a release was tagged the next day and sat there. the only evidence
was a word missing from a card.

the obvious fix is for the platform to respawn the loop, and docs/command-hq.md
settled on 2026-08-18 that it must not: the agent is only notified about a
process it started and is tracking, so a platform-spawned watch would run
correctly, fire, and reach nobody. that trades a visible failure for an
invisible one, which is worse — not watching is at least legible.

so the platform notices and hands the turn back. the agent re-arms itself, the
way it already does after every fire, and the process it starts is tracked.
the owner's two conditions hold by construction: the commander is still the
only thing that starts a watch, and the indicator still reads the pid file.

DETECTION is the pid file, not watcher_armed. that column is a claim only one
path sets and most commanders never take it — both commanders here read false
while one was demonstrably watching. a pid file that exists with nothing alive
behind it means a watch that was running and is not.

which made stop ambiguous, so stopCommanderProcesses now removes the file.
without that, a deliberate stop looks exactly like a crash and the reviver
would ask the commander to re-arm what the owner just switched off.

the commander now keeps WATCHERS.md — what each watch is, the exact command,
the interval, what it does when it fires — because when the wake arrives the
transcript is long past the turn that armed it and the platform deliberately
does not remember the command. a commander predating the file may rebuild from
JOB.md, a script in its directory, or the turn that armed it; inventing one it
cannot find is forbidden, since a wrong watch looks like a working one.

the owner is notified separately from the commander being woken. they are
different audiences and only one of them can act when the wake does not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 16:27:39 +00:00
pastilhasandClaude Opus 5 16da10150b todo: a commander's watcher does not survive a reboot
the 2026.08.25.01 tag was pushed and nothing picked it up. commander 40's
loop-pid held 291464, dead; its last state file was written 2026-08-24 03:50,
and the machine came up at 12:40 the same day. no cron, and deploy-watch.sh
was not running either. commander 54 says "watching" only because it was armed
after that boot.

the watcher is a bare background process — not pm2, not systemd, parented to
whichever agent turn armed it. so a reboot ends it silently, and the only
signal is the absence of a word on a card. that breaks the one promise that
makes a commander worth having, quietly.

ranked by what it buys: SAY IT (a row that claims armed with a dead pid is
already detectable — router.ts computes watcherArmed and watcherRunning
separately for exactly this reason, so notify once and nothing else on the
list matters), then re-arm on boot, then stop it being a bare process at all.

interim: re-arm by hand after a reboot, and trust watcherRunning over
yesterday.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 16:18:09 +00:00
46 changed files with 2144 additions and 136 deletions
+5
View File
@@ -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 —
+39 -37
View File
@@ -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)
+131
View File
@@ -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.
+82
View File
@@ -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.
+170
View File
@@ -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.
+57
View File
@@ -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.
+247
View File
@@ -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
View File
@@ -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 (
+3 -3
View File
@@ -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…",
+3 -3
View File
@@ -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…",
+3 -3
View File
@@ -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…",
+3 -3
View File
@@ -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 à…",
+3 -3
View File
@@ -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…",
+3 -3
View File
@@ -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…",
+2 -2
View File
@@ -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)
+2
View File
@@ -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.
+23 -5
View File
@@ -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);
});
});
+4 -2
View File
@@ -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) => {
+120 -3
View File
@@ -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.
+27
View File
@@ -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',
+21
View File
@@ -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');
}
+15 -2
View File
@@ -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;
}
+35
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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 };
+24 -3
View File
@@ -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,
+52 -15
View File
@@ -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.
+8
View File
@@ -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.