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
pastilhasandClaude Opus 5 007cc9a1f2 release 2026.08.25.01
96 commits. commanders become usable, agents get a credential that can be
bounded, and a nudge finally arrives.

one schema change, additive: two nullable arrays on api_keys, both null on
every existing key including the mobile apps'. no dependency change.

edge-pertento-bridge is excluded and the plan says why at length, because it
is the part somebody will otherwise get wrong. it came back 288 commits
behind with no roles table and two accounts holding 'Super Admin' and
'Developer' — verified on the host. current schema makes users.role a foreign
key onto roles.name, and the seeder runs from postdb:push, AFTER the push. so
one db:push would create that constraint against an empty roles table. every
other host got the table seeded by 08.23.04 and the constraint by 08.23.05,
in that order, which is why nobody else hits this.

it also missed two plans carrying one-off steps a fast-forward does not
replay. bringing it forward is by hand and watched, straight after this
rollout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 16:13:24 +00:00
pastilhasandClaude Opus 5 8e2670ff1d todo: a plugin declares its secret purposes and the build checks it
encryptSecret/decryptSecret take the purpose as a plain string the caller
types, and nothing checks who is asking — vault passes 'vault', invoices
passes 'invoiceshelf', and either could pass 'wallet'. the per-purpose split
is real; what is missing is anything stopping a plugin naming a purpose that
is not its own.

it cannot be enforced at the call while plugins run in the platform's own
process: a function call carries no identity the callee can trust. the two
designs that would fix it properly — make the plugin a separate process, or
never hand it the key and do the operation on its behalf — were both declined
on 2026-08-25 as too large for now. keep the system, catch it in review.

so this is only the part that makes the review mechanical: declare the
purposes in the manifest, have docs:check fail on an undeclared one. records
what it does not buy, which is protection from a plugin nobody read.

backburner, low. explicitly not urgent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 15:48:59 +00:00
pastilhasandClaude Opus 5 6b8cf848ff a turn this tab did not start is still a turn in progress
"Turn finished · no closing message" kept appearing over an agent that was
plainly still working, with output resuming a moment later.

isGenerating was set in exactly three places: sendPrompt, and the two reconnect
handshakes. So it answered "am I waiting for a reply I asked for", never "is
this conversation working". Every other way a turn begins left it false — a
commander waking on its own schedule, a queued prompt the server delivered,
another tab driving the same session, or a turn that started before this tab
was looking. The events still render, but buildRows reads isGenerating to
decide whether the last turn is finished, so a live turn was `complete` and the
moment it sat on a completed tool call with nothing said after it, the banner
fired. Same gap left the composer enabled mid-turn, and a message typed then
was not marked injected — which splits the live turn and prints the banner a
second way, the exact failure injected exists to prevent.

session:claude now sets it: the harness's own turn-start signal, and the only
one that arrives before any work does. tool:start sets it too, because
session:claude is not durable and a socket connecting mid-turn never receives
one, while a tool cannot start outside a turn.

Two candidates were measured rather than argued. A genuinely silent turn ending
earns the banner honestly, but 667 of 673 recorded turns end with
assistant:text immediately before result, so that is ~1% and cannot be it. And
a stale sync:live is real but rare: correlating every attach in the officer log
against the agent's own later output, 15 of 549 "not generating" attaches were
contradicted and one provably so. That residue is left alone — the tool:start
fallback now corrects it on the next event — and noted on the refresh card,
which is the same seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 15:27:49 +00:00
pastilhasandClaude Fable 5 f56b9de0d6 todo: OPM sits on the docker network, so containers need no published port
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:04:36 +00:00
pastilhasandClaude Fable 5 c4cd42eb29 todo: opencode signs no commits at all, claude is missing only the id
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:54:42 +00:00
pastilhasandClaude Fable 5 21bc460892 todo: deep-link a todo notification to its markdown file
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:49:13 +00:00
pastilhasandClaude Fable 5 31151e7aa6 todo: refresh mid-turn folds the still-running tool call
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:45:37 +00:00
pastilhasandClaude Opus 5 2b720cb2c2 the todo board's barrel and hook, which 420cd075 left behind
420cd075 committed TodoBoardPanel.tsx and the AppRegistry import that reaches
it, but not index.ts or useTodos.ts. they were staged and never named, so the
commit went out without them and master has not built since:

  AppRegistry.tsx(17,52): Cannot find module '../apps/TodoBoard'
  TodoBoardPanel.tsx(8,31): Cannot find module './useTodos'

invisible here because both files exist in the working tree, and this host is
the only one that has them. found while checking whether the tree was fit to
tag — a clean checkout of master could not have built a frontend bundle on any
of the five hosts.

not my work; committed unchanged, untouched since 00:17, and required by a
commit that is already pushed. the two files still dirty in this tree belong to
a session that is live right now and are deliberately not included, which is
why this names its paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 09:38:29 +00:00
pastilhasandClaude Fable 5 c4dba8cb60 todo: a favorites tab/panel for the file browser
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:31:26 +00:00
pastilhasandClaude Fable 5 7d2b8b644d todo: understand claude and opencode memory, then centralize into a vector db
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:27:33 +00:00
pastilhasandClaude Opus 5 ffb4c3eedd a nudge always arrives
three refusals, all gone. the row stayed enabled, sentCount stayed 0, and
whatever scheduled it reported success — so "hand this category to a chat
you already have" could not work and said nothing.

NOT LIVE was fixed elsewhere and the guard outlived it: fire() passes
resumeSessionId and claude-manager resolves it into the SDK's `resume`, so
a collected session resumes its own transcript instead of starting a second
agent. that was the 2026-08-20 incident and it cannot recur through this
path.

ANYTHING ELSE LIVE refused on any agent anywhere on the machine, because
the sidecar does not report working directories and the check could not
tell another agent in this conversation from an unrelated chat. on a box
running commanders that is always true. a ban on the feature wearing the
clothes of a safety check.

GENERATING was refusing to do something that already works. a session is
one long-lived query() driven by a streaming input queue; a message to a
busy one is queued and delivered when the current turn ends. waiting is
fine, disappearing is not, and refusing meant disappearing.

what it costs, and the owner's call: nudging conversation A while B is live
in the same tree still puts two agents in one directory. a message that
must arrive is worth more than a guard that could only ever have been right
by accident, since it never knew the directories either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 09:26:00 +00:00
pastilhasandClaude Opus 5 538a623bb8 every agent session gets a key, scoped to notify
a commander has officer.env and can reach the platform. an ordinary chat
agent had nothing — the only token in the tree lives in the MCP server's
environment, which the agent's own Bash cannot see. so an agent asked to
tell its owner something either could not, or asked to be handed a personal
API key: the owner's full authority, pasted into a transcript. one refused
tonight and was right to.

minted per session, permissions ['notify'], expiring in a week, named
session:<key> so re-arming replaces rather than accumulates and a survivor
is findable rather than one of a hundred identical rows.

"an agent may tell me things" is a far 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. widening this later is one entry in an array;
starting wide and narrowing later never happens.

in the environment rather than a file, because it dies with the process:
nothing to leak, nothing to clean up after a crash, no file for a later
session to find. the commander's credential is a file for the opposite
reason — a detached loop reads it hours later.

members get it too, through the allowlist rather than round it. those
OFFICER_* names sit next to NEVER_ENV deliberately, and they are not that
kind of thing: NEVER_ENV guards the OWNER'S credentials, which would make a
member's shell more privileged than the member. this grants a member one
capability they already have, over their own devices, and nobody else's.

best effort throughout — a turn still starts if minting fails, and the
agent is simply back where it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 09:06:29 +00:00
pastilhasandClaude Opus 5 01cecdad60 notify:test goes through the platform again
it POSTed to the sidecar directly on loopback, and yesterday the sidecar
began requiring proof that the platform is the caller — so the CLI 403'd.
my regression: i noted it in that commit and left it failing.

it mints a key for the one send and revokes it in a finally, whatever
happens. that is legitimate here and nowhere else: the script runs as the
owner, on the owner's machine, with the database already open in front of
it. anything that cannot do that has no business sending either.

the key is named "notify:test (temporary)" so a leftover from a killed run
is obvious in the key list rather than looking like something a person
made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 09:00:56 +00:00
pastilhasandClaude Opus 5 fc474d3061 ask for the chat id, do not offer session names
it offered a choice between platform-13 and platform-94. those are the
platform's own bookkeeping — the owner has never seen one, they change when
a session restarts, and from outside two of them look identical. offering
that choice asks somebody to guess.

what they can actually get is the id in the address bar: open the chat you
mean and it is in the URL after /chat/. ask for that, in those words, and
take it as free text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:39:08 +00:00
pastilhasandClaude Opus 5 ea4b8a1e5f the ledger records which step finished, not just that one did
"done" stopped being enough the moment there was more than one step. an
item transcribed but never filed and an item that went all the way through
are different states, and a ledger that cannot tell them apart cannot
resume, cannot report, and cannot answer "was this ever sent?".

so: a line per STEP rather than per item. the same hash appears once for
each stage it reaches — acted on, classified, dispatched, and where it went
— and the latest line for a hash is its current state. append-only holds,
because no line is ever edited.

it is also the only way to find what got stuck. an item acted on but never
classified is invisible otherwise: not in the backlog, because the trigger
has seen it, and not finished either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:33:12 +00:00
pastilhasandClaude Opus 5 d1a41d6534 say what dispatch can be, and ask about retention last
a commander was told to ask what should happen to each category and never
told what the answers could be. nobody starting a commander knows that
either — it is the commander's job to know, not the owner's to guess. so
the list is in the file: nothing, somewhere else on this machine, tell
them, wake a conversation they already have, start an agent on it, run
another task, send it to another machine.

with the constraint that stops it becoming a wall: offer the two or three
that plausibly fit what this commander produces. a menu of seven is the
same as no menu.

and retention moved to the end, after notifications. it was between
dispatch and notifications, which asks what to delete before the owner has
finished deciding what happens to the thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:24:06 +00:00
pastilhasandClaude Opus 5 de0590c25b write down the rule, not the result
"5 went to platform, 2 to personal" is a result and it is worthless a week
later. CLASSIFICATION.md should hold what would let somebody else — or the
commander next month — sort the same items the same way: what each category
is for, what the giveaway is, and where it hesitated.

the borderline calls especially. those are the rule's real edges, they are
where it will be wrong first, and they are the only part the owner can
usefully argue with. a scheme with no recorded doubt looks decided, and
then nobody revisits it.

same for DISPATCHING.md as each category's dispatch is agreed — what
happens, to where, and why that category rather than in general. written as
it is agreed, not afterwards from memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:16:09 +00:00
pastilhasandClaude Opus 5 1072e17193 dispatch is per category, and scp is not a workaround
two things, and the second unblocks the first.

the dispatch question is per CATEGORY. sorting items exists because they
deserve different treatment, so one rule for everything defeats the reason
to sort. some categories get nothing, which is an answer. each one goes
into DISPATCHING.md as it is agreed, in the owner's words as much as the
commander's — that file is where somebody looks in three months to find out
why a thing went where it went.

and the rule that would otherwise have stopped it. "use the platform, never
the thing behind it" is about not going ROUND a route that exists — writing
to the database instead of calling the endpoint in front of it. it is not a
rule against scp, ssh or rsync when the owner asks and the platform offers
no equivalent. copying a file to another machine is exactly that: no route,
so nothing bypassed.

the owner's call, and the right one — a route for this would mean making
the host list a real thing and deciding what a member may reach, to
replace a command that already works for the one person who can use it.

reachability, keys and logins are the owner's to arrange. the commander
says when something is unreachable rather than trying to fix their SSH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:12:59 +00:00
pastilhasandClaude Opus 5 efe01e3019 ask what survives an item that has been fully handled
a pipeline leaves copies in several places — the original in the watched
folder, what the action produced, whatever classification moved. nobody
decided that, it is just what happens, and it accumulates quietly until
somebody notices months later.

so once the whole path is settled, the commander asks. two questions, not
one, because they have different answers and very different consequences:
the source, and the intermediate things nothing reads any more.

default to keeping, and never infer permission for either. everything a
commander makes can be made again from the source; the source cannot be
made again from anything. deleting there is the only thing it does that
re-running will not repair, and it happens in a folder that is the owner's
rather than its own.

and asked at the end, once the rest works and they can see what it
produces — not during setup, when they would be agreeing to deletions in a
pipeline they have not watched run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:06:13 +00:00
pastilhasandClaude Opus 5 ab79470356 a commander that classifies should propose being told when it cannot
the owner had to think of it. a commander that files items into categories
already knows the one event where a person changes the outcome — an item it
could not place — and it should offer that rather than asking an open
question about what should happen with results.

the loop it completes: the owner says where that one belongs, and the
commander turns the answer into a rule so it never comes back.

with the other half, which is why "notify me about everything" is the wrong
default: the owner started this and assumes it works. being told what
worked trains somebody to stop reading, and then the one message that
mattered arrives in a queue they no longer look at.

stated beyond classification, because the shape is general — any step that
can fail to reach a conclusion has it. the failure is where a person is
worth interrupting; the success is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:03:37 +00:00
pastilhasandClaude Opus 5 a35df2afb2 dispatch is anything that leaves, and the deeplink now goes somewhere
the definition was "doing something with a result beyond keeping it",
which does not obviously cover a notification ABOUT classification failing.
it should: classification decided where the item goes and it went to
unclassified/. telling somebody is a different step, and its rule belongs
in DISPATCHING.md. the test is never what the message is about — it is
whether anything left.

and the commander notification pointed at officer://command-hq/{id} where
{id} is the DIRECTORY name, while /command-hq/:sessionId addresses a
commander by its transcript id. so the tap landed nowhere.

the two want different ids and cannot be reconciled today: threading wants
the directory name, stable from creation; the route wants the transcript
id, which does not exist when the commander is created. rather than make a
commander fetch its own transcript id before it can speak — an errand it
would rightly skip, as the language rule already proved — the tap opens
Command HQ and the owner picks. one tap worse, and correct.

the honest fix is a route that accepts a directory name. noted where
whoever does the mobile deep-link work will find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 08:02:34 +00:00
pastilhasandClaude Opus 5 141e470aee an option not chosen is not an option refused
asked classification or dispatch or both, the owner picked classification.
the next question offered "leave it running" and "notifications" — dispatch
had quietly dropped off the menu, because it read a "first" as a "instead
of".

so: anything still unsettled stays on the menu until it is settled or the
owner says to stop offering it. otherwise a "not yet" becomes a "no" they
never said, and remembering the option you stopped offering becomes their
job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:57:00 +00:00
pastilhasandClaude Opus 5 92fec50fcb a spawned agent runs on the cheapest model that works
a commander spawned a classification agent on Opus, spent $0.26 deciding
which of three folders a transcript belonged in, noticed, and said it would
pin Haiku instead. it was right and nothing had told it — the model rule
lives in the delegating skill, which is about the platform's own agents.

so it is in the template now: haiku for anything mechanical and repeated,
sonnet where there is real judgement, opus almost never — and if a spawned
agent seems to need opus, the rules in CLASSIFICATION.md are too vague and
sharpening them is cheaper than paying for the same decision every time.

with the reason it matters more here than anywhere: a commander fires once
per item, forever. the difference between models is not one decision, it is
one decision multiplied by however long the owner keeps using it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:53:50 +00:00
pastilhasandClaude Opus 5 4d2ee284ff the card shows which directory it is
a commander's card gave its name and its cost and nothing to connect it to
`commanders/<n>/` on disk. the owner is the only person who will ever look
at that directory, and finding out which one a card was meant opening it
and reading a path out of a tool call.

#40 beside the title, monospaced and muted, with the full path on hover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:46:29 +00:00
pastilhasandClaude Opus 5 a3fced25c4 one buffer per file, shared by every panel showing it
The code editor held file contents per panel while Monaco keyed the model by
file URI globally, so two panels on one file had two buffers and one model.
@monaco-editor/react resolves a model as getModel(uri) || createModel(value),
so the second panel adopted the first one's model and its own value was
ignored; the value-sync effect then overwrote the shared model with
executeEdits, and the flag suppressing onChange around that write is
per-instance — so the OTHER panel's onChange fired, and the editor answers that
with setContent. One panel's buffer silently became the other's and the tab
went dirty with no keystroke.

useEditorState now keys buffers by path in one shared store and keeps only the
tab set and active path per panel. openFile discards the content it is handed
when the file is already open elsewhere, mirroring what Monaco does with the
model — that is what removes the divergence, because there is then no value for
the sync effect to reconcile. Buffers are refcounted by holders and dropped
when the last panel closes, so closing a tab cannot discard another panel's
unsaved work; closing the last one still discards, as before.

keepCurrentModel is the other half and was needed under any fix: the default
disposes the shared model on unmount, and key={activeFile.path} makes that
every tab switch rather than only a panel close.

Writes go through queryClient.setQueryData rather than useGlobal's setter,
which applies a functional update to the data captured in render — invisible
with one writer, wrong with one per panel.

Nine tests, and the editor had none before. The known-bugs card stays OPEN: this
removes a mechanism that produces the reported shape, not the mystery of where
the log text came from. The trap stays armed, and it can now fire legitimately
when two panels share a file, so the stack matters more than the count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 07:45:32 +00:00
pastilhasandClaude Opus 5 27a8c29fe4 fix the missing comma in the previous commit
4564d6eb went out with a syntax error in directory.ts — an array element
without its trailing comma. tsgo said so and i pushed and restarted anyway
rather than reading its exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:43:26 +00:00
pastilhasandClaude Opus 5 4564d6eb60 tell the commander the language, do not send it to fetch one
the rule was in CLAUDE.md and it was ignored: the transcript shows two Bash
calls — ls the folder, curl /api/tasks — then the question, in English.
GET /api/user/settings was never called.

which is reasonable of it. its brief says the first message is what it
found plus one question and NOTHING else, so an errand before speaking is
exactly what it drops. an instruction that competes with another
instruction loses.

so the platform says it outright: the brief now names the language, from
the locale the client is already rendering in. no call, nothing to weigh.
CLAUDE.md keeps the rule for later sessions and points at the endpoint only
as somewhere to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:42:50 +00:00
pastilhasandClaude Opus 5 941259ce4f a commander talks in the owner's language
nothing told it the owner had chosen one. user_settings.languages.default
is pt-PT on this account and every commander answered in English, including
the questions and their options.

GET /api/user/settings, languages.default, read on the first turn — in
CLAUDE.md so it holds for every session rather than only the one the brief
opens, and in the brief too because the first message is the one that sets
the tone.

with the boundary, because it is the part that would go wrong: only the
words addressed to the owner. not code, not filenames, and not the contents
of what it handles — a transcript stays in whatever language it was spoken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:38:14 +00:00
pastilhasandClaude Opus 5 ed2a343676 CLAUDE.md is the platform's; the commander writes its own files
it was told to extend and rewrite this file as the job settled, which
makes every improvement to the template fight a commander's edits and means
a backfill can never replace it. the file is the same for every commander
and should stay that way.

so: never edit it. everything the commander decides goes in files beside
it. JOB.md — what it watches for and what it does — written the moment that
is agreed, and read before anything else on every session, because
CLAUDE.md is the method and JOB.md is the answer. then a file per thing
with rules of its own: CLASSIFICATION.md, DISPATCHING.md. those change, and
a rule that changes needs somewhere it can be read, argued with and edited
without touching anything else.

the spawned-agent rule points at those by name now instead of at a
procedures/ file nobody had a reason to open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:29:45 +00:00
pastilhasandClaude Opus 5 02d44b93a4 never end on a report
it set up classification, reported how the eight transcripts landed, and
stopped. correct work, and then silence — leaving the owner to work out
both that it was their turn and what the remaining choices were.

so: every time a step lands, say what happened and ask what next in the
same breath. three options, always the same three shapes — refine what was
just set up, move to the next unsettled thing, or leave it as it is.

the commander holds the map of what is still open. the owner has no reason
to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:25:22 +00:00
pastilhasandClaude Opus 5 22a9d58d5c a deleted commander stays deleted, and the header shows its name
two things that should have been settled already.

commanders/46 came back as an empty directory after a clean delete. not a
surviving loop — nothing from 46 was running. resolveChatCwd did
`mkdirSync(dir, {recursive:true})` on every commander turn, so one
in-flight turn from the conversation that outlived the row recreated it.
an empty directory is worse than none: a commander with no CLAUDE.md that
knows nothing about itself, showing up in the file browser as something
that still exists.

the directory is created by POST /command-hq/commanders before any turn
spawns, so a missing one means deleted. no mkdir here — fall back to the
home and say so, the same degradation the missing-row branch already
reports.

and the detail header showed the harness's own summary title, which it
makes from the first prompt: "You have been named "Voice Memos". Look in
`/home/…`", on a commander whose name was correct everywhere else. the list
route already prefers row.name. this now does too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:21:11 +00:00
pastilhasandClaude Opus 5 f16aa87d8b the code editor collides with itself: two panels, one model, two buffers
The "two Monaco editors sharing a model" elimination asked which OTHER
component mounts Monaco and never asked whether the code editor collides with
itself. It does — the panel is addable to a dashboard twice, and useEditorState
keeps files in a plain useState, so two panels hold two independent buffers for
one globally-keyed model.

Verified against @monaco-editor/react@4.7.0's built source rather than inferred.
Model lookup is getModel(uri) || createModel(value), so the second mount adopts
the first panel's buffer and ignores its own freshly-read value. The value-sync
effect then overwrites the shared model with executeEdits. And the flag that
suppresses onChange during that write is per-instance, so one panel's
programmatic write fires the OTHER panel's onChange — which CodeEditor answers
with setContent(path, value). The other panel's buffer becomes this one's
content and goes dirty with no keystroke: dirty tab, content nobody typed, disk
untouched, fixed by reopening. Exactly the reported shape.

Unmount also disposes the shared model (keepCurrentModel defaults false), and
key={activeFile.path} means that fires on every tab switch, not only on panel
close — so one panel can dispose a model another is still displaying.

Not yet a closed case: nothing in the app writes job output into
useEditorState, so this produces the right shape but does not explain where the
log text comes from. No fix shipped — the two candidates (per-panel model URIs
vs sharing the state to match the shared model) differ in whether Monaco's
TypeScript resolution keeps working, and that is the owner's call. Trap stays
armed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 07:16:22 +00:00
pastilhasandClaude Opus 5 7a651aee77 a job's title is a name, not a status line
"Relaunch, loop, wired in" is what happened during one launch. the owner
reads that title for weeks, in a panel already scoped to this commander,
and wants to know what the thing IS. "Classification". "Transcription".

the rule was in the commander skill, which is read on demand. this file is
read every session, which is where a rule about every job belongs.

with the second half, which is the same mistake one level down: leave the
commander's own name out of the title, because the panel is already inside
it and every row would say the same word twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:15:58 +00:00
pastilhasandClaude Opus 5 be56c0c7cd offer a first draft instead of a blank page
asked how categories should be decided, it offered "you name them now" and
"let's talk about it" — and the owner had to type "take a first try and
lets see how it lands" into the Other box. it had already transcribed eight
memos. it could have read them and proposed a set.

so: where you could draft the answer yourself, that is the FIRST option.
naming them outright is the second, for somebody who already knows.

stated generally, because categories are only the first instance: any time
the answer is something you could sketch from what you already have, sketch
it and ask them to correct it. correcting a draft is quick, inventing one
is work, and it is work the commander is better placed to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 07:08:36 +00:00
pastilhasandClaude Opus 5 9bf31eb53c /chat read the model from location.state, which a refresh does not have
The composer showed the bare harness "claude-code" instead of the model. Root
cause: NewChat called useChat(undefined, locationState?.model). location.state
is only set by an in-app navigation, so on a refresh or a deep link it is empty
and the model argument was undefined. That is what useChat seeds selectedModel
from, so the composer sent NO model — confirmed live in the log as
"Model selected for chat … clientModel=null" on the owner's own session. The
server then fell back to DEFAULT_MODEL, the bare harness, which names no tier,
so the picker rendered the raw string.

The same file already knew this. Twelve lines down,
initialModel={props.model ?? locationState?.model} carries a comment saying
locationState.model "is written by nothing, so it was the only source and always
undefined on resume". That was applied to the child prop and missed on the
useChat call above it — the call that decides what gets sent. props.model is
detail.officerModel, correct and populated on every refresh.

Why it looked random: clicking a session from the list carries location state,
so the picker was right; only a refresh lost it. And it never healed, because no
model sent means the server re-derives the bare harness and record() rightly
refuses to store a tierless one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 07:01:21 +00:00
pastilhasandClaude Opus 5 95dd9740da one question per AskUserQuestion call, not one per turn
"one question at a time" was read as one turn at a time. the commander put
two questions in a single call — the tool takes up to four — so they
rendered stacked, the second below the fold, and the owner answered the
first and sat waiting for a commander that was waiting for them.

so the rule is about the call shape now, not the intent: the `questions`
array has exactly ONE entry, every time. ask, wait, ask the next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:58:16 +00:00
pastilhasandClaude Opus 5 92841efac9 the chat panel seeded the model picker from the wrong id space
/chat and Command HQ both pass detail.officerModel into the picker, each with a
long comment saying why. ChatPanelWrapper passed detail.model — what the API
served (claude-fable-5) rather than what the picker offers (claude-code/fable).
The server already resolves the right one, falling back to a family guess from
the transcript, so the panel was discarding a correct answer to show a raw id.

It is sent back, too. isClaudeModel is startsWith('claude-code'), so
claude-fable-5 fails it and the turn dispatches to OpenCode. The clamp that
would catch it only fires while officer still holds the session in memory,
which is exactly not the case after a restart.

This is one half of the reported "the dropdown just says claude-code". The
other half — DEFAULT_MODEL being the bare harness, every adopted session
carrying it, and record() rightly refusing to store it so nothing self-heals —
is written up in todo/known-bugs/the-model-picker-shows-the-bare-harness.md
with the fix it needs: model:resolved already carries the answer and is already
display-only on the client, but it is durable:false, so a refresh never sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 06:52:39 +00:00
pastilhasandClaude Opus 5 f07a1c8cc7 notifications are the last question, not the second
they were adjacent — what next, then be notified? — which asks how the
commander should TALK to its owner before the owner knows what it does.

so: ask what next, work through whatever they choose one question at a
time, and only when all of that is settled ask about notifications. never
beside the other one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:48:36 +00:00
pastilhasandClaude Opus 5 5a4fcd5a24 the armed report is two sentences, then two questions
what a good one reads like: all 8 memos are transcribed into data/, the
loop is watching for new ones, and it transcribes new recordings as soon as
they land. what it must not read like: keyed by content hash in
state/ledger.tsv so renames do not redo work, transcripts never go back
into the watched folder, no classification and no notifications. that is
the mechanics, and the owner did not ask.

somebody who wants the mechanics will ask for them, and then it can be
explained properly. nobody has ever wanted them in the sentence that tells
them the thing is running.

then the two questions, given as options rather than left to invent: what
next — classification, dispatching, both, or leave it running and come back
to it. and whether to be notified — every action, a count every so often or
at a time of day, none for now, or talk about it.

with the constraint that will otherwise bite: AskUserQuestion takes at most
FOUR options, so "let's talk about it" is one of the four rather than a
fifth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:40:02 +00:00
pastilhasandClaude Opus 5 0096392cde a task the agent woke up to starts a turn
Refreshing a chat lost the agent's answers: the turn folded to a seam and
printed "Turn finished · no closing message" over a turn that had plainly
answered. Reported against a commander, where it happened on most refreshes.

The durable log was innocent. A refresh renders from Claude's .jsonl, and the
harness delivers a finished background task as the agent's next user message —
a <task-notification> envelope. The parser dropped it so it would not replay as
a raw XML bubble you appear to have typed, and dropped a turn boundary with it,
because turn-rows derives turns from the message list. The woken turn merged
into the one before it, so that turn's closing prose became mid-turn work and
folded, and the merged segment ended on a tool call, which earned the marker.

Measured on the reported session: 30 messages, one user message, one fold
hiding seven answers, the marker reproduced at 21 of 30 refresh points.

The envelope now rebuilds into the same task row a live task:notification
produces, carrying startsTurn. buildRows makes that a boundary and buildTurn
exposes it as the turn's opener. Separately, the marker is withheld when a
segment ends on a tool call we never saw come back — still running, or cut off
holding it. A completed trailing tool call keeps its marker, so the test
pinning that live behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 06:37:21 +00:00
pastilhasandClaude Opus 5 b5c91a81c5 three questions before arming, and no invented agreements
it asked whether to send notifications before it had armed anything —
dispatch, raised in the setup, about a thing that did not exist yet. so the
pre-arm conversation is now exactly three questions and says so: what to do
with a new one, what about the ones already there, how often to look. then
arm. classification and dispatch come after, if at all.

and the worse half. having asked only about notifications, it reported "no
classification, no notifications — you said just keep them". nobody had
mentioned classification. that is not an unnecessary question, which can be
ignored; it is a decision put in the owner's mouth, which they will only
discover was invented when it turns out to be wrong. say what WAS agreed
and nothing about what was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:31:57 +00:00
pastilhasandClaude Opus 5 1b2a2d0a78 the watcher shows above the composer, named for what it watches
"watching" was a word in the metadata bar at the top, next to the token
count. the thing the owner actually reads is the strip above the input,
where background tasks live — and a watcher had nothing there at all, so a
commander quietly doing its job looked like a commander doing nothing.

WatcherStrip, in the same component and the same place as the task chips,
because to the person reading it is the same KIND of thing: something
running on their behalf that they want to see without scrolling.

it is NOT a background task and cannot be derived like one — a watcher is a
detached loop that outlives the turn that armed it, so it has no task id
and never appears in `messages`. the platform knows it from a pid file, and
only the screen holding the commander's metadata knows what it watches. so
it is passed in.

named for the subject rather than the fact: "Voice Memos", not "watcher
armed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:27:36 +00:00
pastilhasandClaude Opus 5 f4e983693e the status dot was on the wrong axis
a commander watching its folder perfectly showed AMBER, titled "Armed, not
currently running", because green was keyed on `running` — which is an
agent TURN in progress, not the loop. so the healthy steady state, which is
what a commander is in for weeks at a time, was painted as the failure
colour and the owner reasonably concluded nothing was watching.

the failure amber was designed for could not happen either: watcherArmed is
computed from the live pid, so a dead loop turns it off rather than leaving
the pair mismatched.

the server now reports `watcherRunning` separately — the loop pid is alive,
right now — which is what makes the distinction available at all. green is
watching, amber is a row that says armed while its loop is gone, grey is a
commander that never watched.

and the flow: after arming, do not wait for the backlog. the pipeline was
proved on one item, so the rest working is expected rather than news — say
it is armed and move to the next question. a failure in the backlog is
worth saying; a success is not. then ask what comes next, because a watcher
that quietly does one job is a complete answer and the conversation should
offer it rather than assume it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:23:44 +00:00
pastilhasandClaude Fable 5 ee1cd006af todo: refresh loses a finished turn's output — replay shows an empty fold
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:12:42 +00:00
pastilhasandClaude Opus 5 70acccfe0c settle the file yourself; do not ask how it got there
a commander asked the owner whether memos are synced by a tool or written
directly, so it could decide whether the atomic-rename guarantee applied.
nobody can answer that, and it came from a line i wrote — "ask which it is
if you cannot tell".

it does not need to know. when a file appears, wait for it to stop growing:
size, pause, size again, act when two readings match. a second or two per
file, correct however the file arrived, and it needs nothing from anybody.

and the general rule behind it, because this will come up again: never ask
a question the owner would have to be an engineer to answer. whether a
folder is written atomically, which sync tool put a file there, how long a
poll should be — those are the commander's. pick the safe answer and get
on with it. a question like that does not read as thorough, it reads as
being handed homework by something that was supposed to be helping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:09:46 +00:00
pastilhasandClaude Opus 5 47db0383c7 prove on one item then arm, and ask the API instead of searching for it
read the transcript of a real setup: 6m39s, 44 tool calls, and two things
took most of it.

forty-five seconds hunting the filesystem for what one endpoint answers.
two finds in the wrong capabilities directories, three greps through
src/servers, a Read of the tasks router itself, and then `find /` across
the whole machine — to learn which inputs a task takes, which
GET /api/tasks/<dirName> returns in one call. that one is partly mine: i
stripped the endpoint out when the template stopped naming transcription
specifics, and left "never change the platform" without its positive half.
so: ask the API, it describes itself, and never read the platform to find
out what something is.

two minutes waiting for the backlog. it armed, then slept 8, slept 20,
slept 45, opened a Monitor, scheduled a wakeup at 60s and tailed
repeatedly — all so it could say "armed" only after eight existing memos
had finished. now: run the action against ONE item to prove the pipeline,
then arm, and let the watcher take the backlog on its first pass. that is
what a watcher is for, and it scales with the work rather than with how
long the owner has had that folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 06:02:21 +00:00
pastilhasandClaude Opus 5 33e8e70e06 inotify-tools in the core package set
`inotifywait` is what lets a commander react the moment a file lands
instead of polling for it, and the commander template now tells them to use
it. it was on this machine by accident and missing on alpha, which is
exactly the shape of thing that works for whoever wrote it and fails for
everyone else.

all three Linux package managers. macOS has no equivalent in these sets, so
a commander there polls — stated in the comment rather than left to be
discovered.

installed on alpha by hand as well, since setup is not re-run on a live
host for one package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:56:48 +00:00
pastilhasandClaude Opus 5 e201bceafb one card per tool call, however often it is announced
a tool's input ARRIVES — the SDK emits tool:start again as more of it
streams in — and this appended a new message every time. an AskUserQuestion
therefore rendered twice: once half-built, with two of its three options
still missing their descriptions, then again complete. the second carried
the answer; the first sat above it forever, showing an unanswered question
that could not be answered.

upsert on toolCallId instead, and only when there is one — a call with no
id cannot be recognised later, and collapsing those on anything else would
merge two genuinely different calls.

the existing message's other fields survive: `output` arrives on
tool:result, and a late tool:start must not erase it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:49:27 +00:00
pastilhasandClaude Opus 5 d4b26be706 the directory is called data
it was `classification/`, and a commander produced `transcripts/` anyway —
which is the same mistake one level up: a name taken from what this
particular commander happens to make.

`data/` describes the ROLE the directory plays, and that is identical for
every commander there will ever be. classification is a STEP, not a place:
when it runs, it moves items into subdirectories of `data/`. the place is
always `data/`.

so a commander that does not classify has a flat `data/` and that is the
whole story. one that does has `data/<category>/` and `data/unclassified/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:44:54 +00:00
pastilhasandClaude Opus 5 0fe02c84af a commander notices, reports, and stops
asked to run a task, it hit an error, decided the platform was emitting
invalid JSON, and started editing the server to fix it. it was wrong about
the cause and it should not have been looking either way.

so: never change the platform. not its code, not its config, not a plugin,
not a task, and not to work around a bug it has just found. it runs ON this
machine; it does not maintain it. a fix that looked obvious at four in the
morning is exactly the kind that breaks something it cannot see.

when something is genuinely wrong, stop and write it down — what was
called, what came back, what was expected — then tell the owner in one line
and wait. noticing precisely is the job. fixing is the owner's.

and not a workaround either: a workaround hides the fault, and the next
person to look finds a commander doing something strange for a reason
nobody recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:29:20 +00:00
pastilhasandClaude Opus 5 27bd2ac768 chat attachments too
the one the sweep missed. an image or a PDF dropped into a chat went
straight to its final name in the attachments directory, and the mkdir
above it was redundant once Bun.write creates the parent chain anyway.

no `Bun.write` left under src/servers/api that is not writeAtomic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:27:52 +00:00
pastilhasandClaude Opus 5 e87323f71a writeAtomic, everywhere a file lands
the .part-then-rename rule was applied at two upload routes yesterday and
by this morning the platform had a dozen other writes putting files in the
same folders — an OCR result beside its image, a TTS clip, a download
landing, a save from the editor — every one straight to the final name. a
guarantee that holds for two of fourteen paths is not a guarantee, and the
next person adding a fifteenth had nothing to copy.

so it is a function now, and every durable write goes through it: the file
browser, sendoff, task definitions, skill and tool files, bug reports,
scraped attachments, the chat provider config.

the failure it prevents is silent rather than loud. a truncated m4a decodes
fine, it is simply short — a commander transcribing recordings as they land
would produce a clean transcript of the first few seconds of a two-minute
memo and report success.

two things learned writing it. Bun.write creates the parent chain itself,
so the mkdir option i added was dead weight and several callers were doing
it twice. and Parameters<typeof Bun.write>[1] resolves to the first
overload only — BunFile — which rejects the string and Buffer every caller
actually passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:26:40 +00:00
pastilhasandClaude Opus 5 20b270ef07 a trigger can fire on the rename, not on a timer
the cadence question offered fifteen minutes or hourly, because nothing had
told the commander that immediate was available. inotifywait on the
reference path fires the moment a file appears, and that is the honest
answer whenever the owner wants it handled straight away.

it is safe here specifically, and the commander needs to know why:
everything Officer writes into a folder is written as <name>.part and
renamed into place, and a rename is atomic — so a file appearing under its
real name is complete. ignore .part, act on the rename.

with the limit, because it is Officer's guarantee and not the filesystem's:
scp, a sync client or a script writing directly makes no such promise, and
there a poll that waits for the size to settle is the safer trigger. ask
which it is if you cannot tell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:20:11 +00:00
pastilhasandClaude Opus 5 3e10c43bd5 "let's talk about it", and the question about what is already there
every AskUserQuestion offers the real answers plus one more: let's talk
about it. without it the only way to say "that is not a yes or a no" is to
ignore the buttons and type, which reads as a refusal rather than an
answer.

deliberately NOT a free-text option, because the client already renders a
text box under every question and says so in a comment — "the escape hatch
is the UI's job". offering one would duplicate it.

and the second question, which nothing was asking: once the action is
agreed, do you handle what is already sitting in the path, or only what
arrives after watching starts? it matters more than it sounds. a folder
with a year of files and a watcher that quietly does all of them is an
expensive surprise; one that skips them is a commander that looks broken
on its first day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:14:13 +00:00
pastilhasandClaude Opus 5 6041379e98 the first message is two sentences, and the question is tappable
the opening brief was thirty lines and the commander answered in kind: it
explained what the machine already has, what it can be told to do, what it
would not do on its own, and what it inferred from the filenames about
whether a person or a device named them. all of it true, none of it asked,
and the one question was at the bottom.

the brief is five lines now. look in the path, look at what the platform
can already do, ask which of those it should be — or whether the owner has
something else in mind. that is the whole of the first message.

and it asks with AskUserQuestion, so the owner picks instead of typing.
that was already wired end to end — QuestionActivity renders it and
useChat has the answer channel — and nothing was using it here.

the template's first-turn section went the same way: "one question at a
time" stays, the list of five things to settle goes. it was a checklist,
and a checklist gets answered as a checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:10:51 +00:00
pastilhasandClaude Opus 5 facf1a5f9e commanders run on Sonnet, not the account default
every new commander came up on whatever the owner last picked in a chat —
Fable, currently. that default is chosen for interactive work, and a
commander is the opposite: long-running, mostly repeating one settled job,
and started by a button rather than by somebody thinking about which model
they want.

COMMANDER_DEFAULT_MODEL, in both places, because the two ways of starting a
commander do not share code — the server's /commanders/new route and the
detail component's own first turn.

per-surface defaults belong in settings eventually. "commanders default to
Sonnet" is the decision; this is where it lives until there is a better
place for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 05:06:39 +00:00
pastilhasandClaude Opus 5 af41144689 the commander is told the rule, not the API
three sentences where there were fourteen. everything an action produces
goes in this directory under classification/, never into the path being
watched, and getting it there is the commander's problem to solve with
whatever ran the action.

what came out was me teaching it the transcription API — read the task's
inputs, here is the endpoint that lists them, pass a destination. that is
one task's detail in a file every commander reads, and the commander can
discover it. the rule is the part only this file can state.

the task half stays and is real: transcribe-audio-file takes an optional
output_dir now, marked `hidden` so the file browser neither renders it nor
counts it as something to ask a person about. without that, declaring it
would have turned the direct right-click run back into a modal with a
field nobody clicking a file has an answer for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 04:59:06 +00:00
pastilhasandClaude Opus 5 2c0e23d4fb a commander moves its results into its own directory
"output goes in this directory" was not enough, because the ACTION decides
where the action writes. the platform's transcribe task files the text
beside the recording — right for a person clicking a file, wrong for a
commander, and the first real first-turn walked straight into it: it
offered to transcribe "the same way", meaning into the folder it watches.

so the rule is now explicit about the case that actually arises. when an
action leaves its result outside this directory, move it here. every time,
without asking.

the reason, in the file, so it is not just an instruction: it puts the
commander's work in the middle of the thing it is watching, where the owner
did not ask for it, and where the next pass has to tell its own output
apart from its subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 04:51:24 +00:00
pastilhasandClaude Fable 5 68f4fe61fa todo: urgent — an affordance for scope, and for the request that assumes another chat's context
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:49:00 +00:00
pastilhasandClaude Fable 5 58dbfc8313 todo: transcripts land in the central inbox, not the commander's own directory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:38:38 +00:00
pastilhasandClaude Fable 5 d6d102111e todo: stage 1 of the memo watcher is live in another session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:35:57 +00:00
pastilhasandClaude Fable 5 db96f53bc2 todos: a backburner column, and the parked items on it
backburner joins TODO_STATUSES and the board's columns; the three items in the
backburner theme move onto the status, so parked is now a lane rather than a
folder you have to remember to open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:34:41 +00:00
pastilhasandClaude Opus 5 03601db69c definitions instead of examples
every example is gone from the commander template. what replaces them is
an exact definition of each of the four words, because an example is a
thing to copy and a definition is a thing to reason from — and a commander
copying an example produced a first turn that read like understanding and
was recitation.

trigger: the condition, on a schedule, answering one question and needing
no judgement. action: the work done to ONE item, usually a task the
platform already has. classification: which category a produced item
belongs to — changes where it IS, never what it is. dispatch: doing
something with a result beyond keeping it, off until armed, and the only
one of the four that acts outside the commander's own directory.

with the line that stops the confusion the first real conversation ran
into: storing the result is NOT dispatch. every action keeps what it
produced, here, by default. that is the action finishing, not a decision
anybody has to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 04:17:14 +00:00
pastilhasandClaude Opus 5 ddabf46bf3 the first turn asks one question, and gets its suggestion from the platform
three corrections, all the owner's, all from watching a real first turn.

it asked too much at once. the section listed five things to settle, so the
commander laid out the trigger, the action AND the destination in its
second message. every question must now be answerable with "yes" or "no, do
this instead" — if the reply needs a paragraph, the question was too big.
turns are cheap; a wrong guess armed for a week is not.

it must not raise the destination at all. output goes in the commander's
own directory, by default and without discussion. i had written "beside the
source is often exactly right", which was wrong and was the thing prompting
the question.

and the suggestion has to come from `GET /api/tasks` and the skills index —
what this platform actually has — not from an example in this file. which
it could not have, because the examples WERE the answer: the first-turn
section spelled out nine .m4a files named memo-<date>-<time>, and the shape
section named "fire on a new recording, transcribe it" as the simplest
commander. it read as a commander that understood the job. it was a
commander reading its own documentation back.

those examples are gone or varied. the template was written from one
commander last night and fitted it perfectly, which proves nothing. the
test is the second one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 04:15:38 +00:00
pastilhasandClaude Fable 5 887f702c05 todo: resurrect projects/apps, with the archaeology attached
the idea is not settled. the card exists so the history is not dug out
twice: user apps were born 2026-02-27 as a publishing system that made a
personal app a first-class workspace panel, /projects was its authoring
surface, and both went a day apart at the end of july — 1440 deletions.

the part worth knowing before rebuilding: the app registry used to be
mutable at runtime, registerApp keyed on email/slug, and the note left
where it stood argues well against it. the plugin system solved the same
problem afterwards by generating at build time, so the first question is
whether a user app is just a plugin with a different author.

nothing on disk was ever touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 03:58:56 +00:00
pastilhasandClaude Opus 5 9dfa83e607 localization: how to add a locale, and it is built now
the doc said "DESIGN, NOT BUILT — a working 5% skeleton". six locales ship,
three catalogues, parity in both directions and a test that catches a dead
one. it has said the wrong thing since the day the work landed.

and there was no procedure for the thing anybody would actually come here
to do. seven files: three catalogues, three registrations, and the picker.
written out because the failure it is written from was somebody doing four
of them — es-ES, de-DE and fr-FR once shipped translated, in perfect key
parity, listed in the picker, and completely dead, because all three
registration files still imported only en-GB and pt-PT.

nothing caught that at the time. the section names what catches it now, so
whoever adds the seventh language knows `bun test` settles it rather than
having to trust that it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 03:45:43 +00:00
pastilhasandClaude Fable 5 420cd07507 /todos: a kanban board over the todo/ tree, owner-only
The filesystem is the database: GET reads the tree through the same module the
index generator now imports, PATCH splices frontmatter (status, priority, due,
order) and never touches a body byte, and every write regenerates TODO.md.
Columns open/doing/blocked/done, native drag between them, selection and theme
filter in the URL. Registry entry 'todos' (kind admin, 22 entries, docs:check
green), isSuperAdmin double-gate on the router.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:18:14 +00:00
pastilhasandClaude Fable 5 fdf10f02f7 todo: regenerate the index
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:08:33 +00:00
pastilhasandClaude Opus 5 236572107b declaring a long timeout was giving a shorter one
Bun stores a per-request timeout in ONE BYTE. anything above 255 seconds
clamps to 255 — so a plugin declaring a generous 1800 got a SHORTER
timeout than one declaring nothing at all, which falls through to 0 and is
unlimited. the line meant to fix this is what armed it.

that is what killed every long transcription. 1800 became 255, the upload
took a second, the connection dropped at 4m16s, and whisperx went on and
finished into a dead socket. every time, all night, while three separate
fixes above it were already live and correct.

six plugins declare more than 255 and all six were getting 255:
transcribe, email and music at 1800, jellyfin at 3600, minesweeper and
photos at 600. fixed here rather than by deleting the declaration from one
router, because the other five have the same bomb.

anything Bun cannot represent becomes 0. a plugin asking for longer than
Bun's maximum is asking for "do not cut this off", and 0 is the only value
that means it.

found by the owner, after i had already looked at this file twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 22:10:41 +00:00
pastilhasandClaude Opus 5 879edbdfb0 boot hang: "is networkd running" was the wrong question
This section did nothing on the machine it was written for, and the machine
was losing two minutes on every boot the whole time.

Both stacks can be ACTIVE at once. Here NetworkManager owned enp1s0 and
wlp2s0, systemd-networkd was up and managing not one of eighty links, and
systemd-networkd-wait-online still burned its full timeout — the journal has
`Timeout occurred while waiting for network connectivity`, 18:31:05 to
18:33:05. The old predicate required networkd to be INACTIVE, so it reported
"nothing to fix — networkd is in charge" and moved on.

What decides it is whether networkd CONFIGURED a link, which networkctl will
say: a link it owns reads `configured`, one it ignores reads `unmanaged`. If
every link is unmanaged the wait can never be satisfied, and masking is safe.

A missing networkctl counts as "yes it does". An unanswerable question must not
read as permission to mask — the failure mode on the other side of this is a
VPS that comes up with no network and no ssh.

`network_manager_name` had the same blind spot and printed "NetworkManager" for
the both-running case, which is exactly the case that was being got wrong. It
now describes the machine instead of picking a winner.

Verified against this box: configures-a-link no, spurious yes, reported as
"NetworkManager (networkd is up but configures nothing)". Masked here by hand;
NetworkManager-wait-online still provides network-online.target, so nothing
that orders after the network lost its ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-24 20:43:40 +00:00
pastilhasandClaude Opus 5 61c546ab79 sidecar timeouts: a constant, not an env var
const TIMEOUTS_ON = false, in create-proxy.ts. flip the line to turn them
back on. no environment variable, nothing to set per host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:56:43 +00:00
pastilhasandClaude Opus 5 fdd53b0dd9 one switch for sidecar timeouts
SIDECAR_TIMEOUTS=1 puts the old behaviour back — Bun's built-in fetch
ceiling outbound, and the inbound drop at whatever each plugin declared.
unset, which is the default, means no ceiling on either leg.

one constant in one file rather than a knob per plugin: turning it on or
off should not mean remembering which of eight routers to edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:55:07 +00:00
pastilhasandClaude Opus 5 a321babb28 no timeouts on a sidecar request
a 42-minute recording was transcribed successfully and the result thrown
away. whisperx logged POST /inference 200 OK; the platform had already
answered 502 "transcribe sidecar unreachable" four minutes in. the work was
done, nobody received it, and the error blamed the machine that had just
finished the job.

timeoutSeconds only ever governed the INBOUND leg — how long Bun holds the
caller's connection. the proxy's own fetch to the sidecar had nothing on
it, so it took Bun's built-in ceiling no matter what a plugin declared.
transcribe declares 1800 and still gave up at four minutes.

both legs now: `timeout: false` on the outbound fetch, unconditionally, and
the inbound timeout applied to every proxied request with 0 — no timeout —
as the default. the owner's call, and the right one: long requests will
keep arriving from everywhere and every ceiling is a number somebody
guessed.

an AbortSignal is deliberately NOT used. it cannot raise Bun's ceiling,
only lower it, and it aborts the response BODY too — this proxy carries
video, and jellyfin declares 3600, so a signal would have cut a film at
exactly one hour of playback. i wrote that version first.

and a timeout no longer reports as "unreachable". one means the sidecar is
not there, the other means it is there and still working. that sentence
sent an hour of debugging at the wrong machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:50:47 +00:00
pastilhasandClaude Opus 5 955837b5bb edge-pertento is a deploy target again
back up on 2026-08-24. moved out of the skipped list and into the table.

recorded what it came back as, because it is the shape of the problem
rather than a detail about one host: 215 commits behind at 2026.08.22.02,
no roles table, no commanders. a host coming off that list needs its schema
brought forward before it is treated as ordinary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:45:22 +00:00
pastilhasandClaude Opus 5 a9dd264a72 a commander's children stay out of the chat list
/chat already hid commanders. it did not hid their spawned agents, and a
commander that fires once an hour spawns one per run — each with its own
transcript, titled after whatever its first message happened to be. one
commander had thirteen sessions under its directory, twelve of them reading
"Read /home/.../procedures/classify-t…". a loop's bookkeeping, filling the
list you keep conversations in.

listCommandHqOwnedSessionIds is the union: commanders plus their children.
kept separate from listCommandHqSessionIds rather than replacing it,
because a CHILD is not a commander — isCommander has to stay false for one
or the composer would offer it the handoff watcher.

/chat/live is deliberately untouched. it is the "what is running right
now" panel, and a spawned agent that is still going belongs there — hiding
it would mean an agent running with nowhere showing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:23:27 +00:00
pastilhasandClaude Opus 5 46ae8051d4 the spawned list collapses
a commander firing once an hour has spawned a hundred children by the
weekend, and every id was rendered as a chip in a wrapping grid above the
transcript. so the longer a commander worked, the more of the screen its
own history took from the conversation you came to read.

collapsed by default, one row: SPAWNED 12, plus how many are still running
and the cost caveat. click to see the ids. local state rather than the URL
— a disclosure is not a selection, nothing addresses it and it should not
survive navigating away.

`n running` is on the collapsed row deliberately. it is the difference
between "twelve happened" and "one is happening", which is the only part
worth interrupting the transcript for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:19:24 +00:00
pastilhasandClaude Opus 5 7e50134200 Stop could not stop a commander that had re-armed
liveLoopPid read the pid file as Number(text.trim()), which is NaN as soon
as there are two lines. the voice-memos commander wrote its pid with `>>`,
so after one re-arm the file had two — and NaN read as "no loop". the Stop
button reported zero processes signalled and left both loops running,
silently. exactly backwards: the more a commander was re-armed, the less
stoppable it became. verified on alpha, where both pids are alive right
now.

so the reader is lenient — every live pid in the file, in order — and
stopCommanderProcesses signals all of them rather than the newest. killing
one of two leaves the folder still watched, which from outside is
indistinguishable from Stop doing nothing.

parseLivePids is split out and tested. the caller resolves a path under
DATA_PATH, so a test of the whole thing would have been a test of this
machine.

three lessons from what the first commander actually wrote, into the
template:

- the DATABASE is a thing behind the platform, and it is the one that gets
  missed. that loop writes nudges with `psql $POSTGRES_URL` when
  PUT /api/chat/sessions/:id/nudge exists. worth knowing: a MEMBER's agent
  could not do this — spawn-as-member.ts builds an env allowlist and throws
  on anything unvetted. the owner's runs with the platform's environment.
- refuse to arm without a credential or a target. that watcher already does
  it, and it is the difference between failing and running all night doing
  nothing while reporting armed.
- overwrite the pid file, append the spawned list. which is the bug above,
  stated where the next commander will read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:15:37 +00:00
pastilhasandClaude Opus 5 dd6d0a8e12 the first turn: look, then ask
setup opened blank. the commander was told the name and the path and then
asked whether it could guess the job — so the first message back was
generic, and the owner had to explain the subject from nothing.

it should read the reference path FIRST and open with what is there: nine
.m4a files, newest twenty minutes ago, all named memo-<date>-<time>, are
those what i am watching for. that is worth ten generic questions, and it
shows immediately whether it is looking at the right thing.

and the section now lists what has to be settled before anything is armed
— what counts as a new item, what happens to one, where the result goes,
whether there is classification at all, how often to look and that the
number is a guess. "have a conversation" was too vague to act on.

the name is a clue, not a specification. it says what the subject is and
never what to do about it, which is how a commander ends up doing
something nobody asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:02:02 +00:00
pastilhasandClaude Opus 5 5ee09a3fb6 act, classify and dispatch are optional; the ledger is not
a commander is a trigger, a ledger, and up to three steps between them.
most need fewer than three, and the simplest useful one has no
classification at all: fire on a new recording, transcribe it, write the
text beside the original. at that point it is a schedule over a task that
already exists, which is the right answer rather than a lesser one.

so the layout is conditional now — classification/ only where there is
classification, procedures/ only where an agent is spawned. a directory
that exists and stays empty reads as something broken.

and "never write into what you watch" was too wide. that was aimed at
BOOKKEEPING — ledger, reports, logs, state — which must not pollute the
subject. output goes wherever the job says, and beside the source with the
same name is often exactly right. as written, a commander would have read
it and refused to do the simplest thing anyone would ask of it.

the ledger stays compulsory in every shape. the .md beside the .m4a is
tempting as the record and is output-as-claim again: it breaks the moment
either file is renamed or moved, which is the bug that started all of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:53:23 +00:00
pastilhasandClaude Opus 5 0a242e15ba one directory layout for every commander
classification/ with a directory per category and an unclassified/ that
always exists, plus procedures/, reports/ and state/.

named for the SHAPE, not the payload. the voice-memos commander called it
transcripts/, which bakes one job into the layout — the same structure has
to hold for a commander over email or photos, and none of those produce
transcripts.

and the distinction that makes the two records make sense together: the
ledger says whether an item was HANDLED, the directory says where it
BELONGS. unclassified/ is finished work waiting on a person rather than a
failure, so nothing re-runs the action on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:47:25 +00:00
pastilhasandClaude Opus 5 c6a92735d3 a commander is told its shape at creation
trigger, action, ledger — settled with the owner over several turns and
true of every commander, so it belongs in the file each one reads at
session start rather than being re-derived in conversation each time.

the part that took the longest to see: an action that takes ONE item all
the way through means a half-finished item never exists, so there is
nothing to queue and no second watcher needed to notice it. this commander
was built as two cycles — transcribe everything, then file everything —
and the second cycle existed only to clean up after the first stopped
early.

and the ledger keys on a hash of the SOURCE. names change (a memo renamed
from a phone), outputs move (a transcript filed into a category folder),
and both of those had already caused the same file to be handled twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:38:33 +00:00
pastilhasandClaude Opus 5 cb2dcfa4cc the notify sidecar stops trusting whoever reaches it
to send one message to four accounts an hour ago i found the sidecar's
ephemeral port and POSTed to it directly, once per user, with no
authentication of any kind. that worked, which was the problem: the same
door was open to anything else on the machine, including a commander with
a shell.

two changes.

POST /api/notify/broadcast, owner only via `ownerOnly` on the permission —
the supported way to say one thing to everyone. teams will narrow it later;
today the choice is everyone or one person.

and the sidecar now requires a secret on everything under /_officer/. it
is generated per boot, held in memory, announced to the platform over the
registration socket beside the port, and never written to disk.

X-Officer-User could not carry this on its own, and my first attempt got
that wrong: it says WHO is asking, and a caller on loopback can write it
itself. only the secret says the PLATFORM is asking. so the check covers
the whole /_officer/ surface rather than just the send route — /_officer/
devices takes the same header to decide whose phones are listed and
deleted.

what it bounds, honestly: a MEMBER's commander runs as its own Linux user
and cannot read this process, so for every account but the owner's this is
kernel-enforced. the owner's own commander runs as the owner, same as the
platform, and is not bounded by this and cannot be — the same reason
`execution` permissions are never grantable. the owner's decision, and
this is a real improvement rather than a boundary.

`bun run notify:test` posts to that port and will now be refused. it is a
developer CLI on a box where the platform is running; left to fail loudly
rather than given a way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:29:48 +00:00
pastilhasandClaude Opus 5 de633f8697 a plain message, in the owner's own words
officer/core:test has fixed words and officer/core:commander prefixes a
name, so there was no way to say one sentence to somebody without it
arriving dressed as something else. this entry adds nothing around the
producer's title and body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:13:58 +00:00
pastilhasandClaude Opus 5 16d02ffee1 notify is core, not grantable
it was an `app` permission and nothing granted it, so every Member's app
got a 403 the moment it tried to register its own push token — silently, at
launch, with no notification ever arriving and nothing saying why. four
member accounts on alpha, none of them able to receive anything. invisible
for days because the owner bypasses the check.

the grant was not what was missing. these are the caller's OWN devices, and
the send route takes its target from the authenticated header rather than
the body (resolve-user.ts), so an account can only ever reach its own
phone. "you may not subscribe to your own notifications" is not a coherent
thing to configure.

6 core, 3 app now. docs:check holds the count and the generated platform
skill was regenerated with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:52:54 +00:00
pastilhasandClaude Opus 5 40a03d987f tell an existing commander how to reach its owner
the voice-memos commander was asked for a notification and reached for its
harness's own push tool — a different channel, which refuses to interrupt a
live terminal. it did that because nothing had told it Officer has a route
of its own. that rule is the harness's; the platform's route has none, and
had already pushed to a phone twice with the owner sitting at the terminal.

so the notify part is its own section now, appendable on its own, and the
backfill lands it on a commander that already has a credential — which is
every commander that predates this, i.e. the ones that need it.

the backfill also stopped being all-or-nothing. it skipped everything after
the credential check, so a commander with an officer.env could never
receive anything added later. each section is now appended independently,
keyed on a marker the section itself contains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:31:37 +00:00
pastilhasandClaude Opus 5 2d5e9835f5 the catalogue shapes a commander's words, not the commander
"it should always say Commander: <name>" was left to whether the commander
remembered to write it. that is not a structure, it is a hope — and the
producer's title reached the lock screen untouched, because both transports
built the alert from n.title rather than from the renderer.

so the code owns the shape and the producer only supplies values.
officer/core:commander is `Commander: {title}` / `{body}`; the commander
sends "Voice Memos", never the prefix. interpolation gains {title} and
{body} alongside {n} and {id}, for the same reason the others exist.

apns-payload and fcm now send the RENDERED text. that was the actual
delivery bug: the catalogue could say whatever it liked and neither
transport read it.

a placeholder that resolves to nothing takes its punctuation with it, or
`Commander: {title}` with no title ships `Commander: ` and every template
has to assume its values are always present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:24:17 +00:00
pastilhasandClaude Opus 5 478ddb13e9 a commander can say something in its own words
asked whether the voice-memos commander could send a notification, the
answer was no, three times over. the platform skill gives it the prefix
/notify and not the route. the route wants a catalogue code, not a title
and a message. and of the six codes that exist, none means "a commander has
something to tell you" — the nearest renders "Test notification".

so: officer/core:commander, one entry rather than one per commander, with
the commander travelling as {id}. arming a new one needs no catalogue
change and no app resync.

and `title`/`body` on the notification itself, which override the
catalogue. this is a deliberate exception to the rule the whole design
rests on — content does not travel — and it is the owner's decision, made
2026-08-24. a commander's output is one sentence only it could write; there
is no entry to render it from, and "1 item" is not worth waking a phone
for. sendoff.landed already carries the same relaxation for the same kind
of reason.

the control sits at the PRODUCER, not the transport. the commander's
CLAUDE.md now says what may go in those two fields and what may never — and
that a notification is an interruption, so send few. this file cannot judge
what is sensitive and does not try.

`title` is the opt-in, not `body`. the existing smuggle test passes a stray
`body` and asserts it does not reach the wire; a body that travelled on its
own would have silently defeated it. pinned on both transports.

FCM gets a `notification` block only for producer text — data-only is what
lets the app compose from its catalogue, and text with no entry to compose
from would arrive and show nothing.

worth recording: there is NO Notification Service Extension, in this repo
or the mobile one. mutable-content: 1 is set for something never built, so
aps.alert is what the phone actually shows and this works with no app
release. i said the opposite an hour ago, from the comments rather than
from looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:13:39 +00:00
pastilhasandClaude Opus 5 00baa83a2c a commander's own files, beside its transcript
a commander writes into its own directory — procedures, reports, the loop
it armed — and there was no way to look at any of it. /files cannot reach
it and never could: the file browser serves two roots, `home` and
`user-data`, and a commander lives at $DATA_PATH/<email>/commanders/<dir>,
which is under the one /files does not use.

so the Plan button becomes a files button on a commander. instead, not
beside: plan mode is research-and-propose-until-a-human-approves, and a
commander is the case where nobody is watching to approve. the control
promised something that cannot happen there, while the thing genuinely
missing was any view of what it had written.

an EPHEMERAL panel, the same trade /files makes for the file viewer.
/command-hq is a locked workspace with a three-item allow-list whose
comments are explicit that its structure is code's rather than the user's;
a persisted panel would mean unlocking it and widening that list. an
ephemeral layout renders beside workspace.value and never enters it, so
neither has to change.

?files=1 rather than a route, and a panel rather than a link: the
transcript stays mounted and stays the page, and a link on this surface
opens a second copy of the whole app in a new tab and loses the
conversation you were reading.

breadcrumbs stop at the commander for free — Breadcrumb renders relative
to basePath and its home crumb IS the base. worth knowing that this is the
UI's floor and not the server's: the root really is all of user-data, which
is the owner's own data either way, so it bounds the navigation rather than
the account.

the button reads its state from props rather than useSearchParams.
ModelSelector is rendered bare in its own tests and reaching for the router
inside it failed all twelve — a control that has to be mounted in a route
to render is not a control any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 14:22:58 +00:00
pastilhasandClaude Opus 5 a3a8516e66 backfill: a credential for a commander that predates them
a commander made before credentials existed has no officer.env and no key
row, so it cannot reach the platform at all — the state that made the first
one curl whisper directly. deleting and recreating it would fix that and
throw away the conversation, which is the one thing a commander IS: a
long-running agreement about a job.

so this is additive and re-runnable. a commander that already has one is
left alone. CLAUDE.md is APPENDED to, never rewritten — it is a living
document its commander has been extending, and surviving is the whole
point of not recreating it — and only when it does not already mention
officer.env, so a hand-edited one does not end up with the section twice.

the credential section moved out of commanderClaudeMd for this: it is now
written in two places, and the backfill needs a self-contained section
rather than a slice of one file's layout.

verified locally: minted for the one commander here, second run reported
"already has a credential" and changed nothing, file is 0600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:57:01 +00:00
pastilhasandClaude Opus 5 12a9605b01 a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.

so a credential now narrows two ways, and both only subtract:

  permissions   an ALLOW list. exact, a snapshot. right for a credential
                with one job.
  excludedKinds kinds it may never reach, resolved against the LIVE
                registry on every request. right for a credential whose
                job is open-ended.

a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.

verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.

the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:47:46 +00:00
pastilhasandClaude Opus 5 e29a4ee380 a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.

api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.

checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.

it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.

POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.

script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.

a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.

default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.

also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:40:42 +00:00
pastilhasandClaude Opus 5 247291eeb9 tasks: a direct run runs, it does not queue
"direct" meant a job row and a link to the jobs page. that is not direct —
it is the same work with a screen nobody opened between you and the result.

inline decides which kind of direct run it is, which is what that field has
always meant. true: run it here and wait, a toast while it works and the
listing refreshed when it lands — the file viewer's old transcribe button,
reached by the menu instead of a header icon. false: a background job,
because a task saying inline false is saying it expects to outlive the
click.

runTaskNow is the promise-shaped sibling of useTaskRunner: same socket,
same protocol, opened for the run and closed after it. the hook could not
be reused — it holds a connection for as long as it is mounted, and a hook
in the file browser would open one per panel, permanently, for a path that
fires on a right-click.

a socket that closes before exit rejects. it did not finish, and a success
toast would be a lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:47:16 +00:00
pastilhasandClaude Opus 5 121ddb9964 tasks: run straight from the menu when there is nothing to ask
a task that takes real options — which audio tracks to keep, what format
to convert to — has to ask, and the runner modal is for that. a task that
is one command over the clicked file has nothing to ask, and putting a
form in front of it with one pre-filled field and a Run button is a click
that decides nothing.

derived, not declared. the rule is whether anything is left over once the
file browser has filled in what it knows, which is two things and only
two: file_path by name, and any input declaring autofill entry_name /
entry_path. a default does NOT count — a default is a suggestion and the
user has to see it to change it. agentic, pipeline, inline true and
inline 'ask' always take the modal; the last because the choice is itself
the question, and the one before it because the modal is where an
ephemeral run's output goes.

and the reason the transcribe task's one field came up EMPTY: inputs is a
list in TASK.md and a map everywhere else. the app's own task-creation
guide teaches the list form, so that is what gets written; every consumer
reads the map. a list arrived at the form as Object.entries(array), key
"0" — so the input rendered nameless, was never matched against what the
browser auto-fills, and would have run with nothing in it. normalised in
parseTaskMd, which fixes the form, the INPUT_<NAME> env vars and the
positional args together. the map form still passes through.

GET /tasks now carries inline and inputs so the menu can decide without a
second request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:40:07 +00:00
pastilhasandClaude Opus 5 6d673a1321 plugins: a plugin's tasks are read where they live
the last piece of docs/plugin-capabilities.md. skills, affordances and
notification catalogues already loaded from each installed and enabled
plugin's own directory; tasks did not, so the transcribe plugin shipped a
task nothing could see — right-clicking an .m4a offered no Run Task at all.

it turned out to be one function rather than several. every path that runs
a task resolves it through getTaskByDirName and then reads implementation
off the record, so the sources go in there and in listTasks and a plugin's
task is runnable everywhere at once, identity unchanged: still a directory
name, still what pipeline_jobs stores.

the record carries `plugin` and that one field does the rest. updateTask
and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the
plugin, and the capability screen shows whose it is instead of an edit and
a delete that cannot work. without the delete guard it was quietly a no-op:
rm -rf on a path in the owner's store that does not exist, then ok: true,
and the task still in the menu.

owner's store first, so a plugin cannot take over a name already in use.

getTaskByDirName now refuses a name containing a separator. it always
joined a caller-supplied string onto a path — from a websocket message and
a job row — and searching several roots is the moment that stops being one
directory nobody could escape.

capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and
carries the provenance, with capabilityRoots the thin map over it that the
loaders that only want paths still use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:20:18 +00:00
pastilhasandClaude Opus 5 50e96e0d77 plugin settings: the client already adds /api, and a Test button
Save returned 404 and the form silently showed what was typed rather than what
was stored. Both from one mistake: useClient prefixes /api itself, so
`/api${prefix}/config` reached /api/api/transcribe/config. Every other caller in
the tree passes a bare path; I copied the shape of a curl instead.

The GET failing was the worse half, because it looked like it worked — an empty
field populated by typing is indistinguishable from one populated by the server.

Adds Test, which asks the plugin's /_health whether its connection works NOW. The
plugin validates on save, so a stored value has been reached once — but once,
whenever that was, is not the same as now, and the server it points at is a
separate machine that can stop answering. Offered only once something is stored,
since there is nothing to test before that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:07:16 +00:00
pastilhasandClaude Opus 5 c427cb5b61 plugins: a backend plugin can have settings
A plugin with no web/ has no route, no dock tile and no panel — so it had
nowhere to put a setting. transcribe shipped exactly like that: installed,
running, and configurable only by an HTTP call somebody had to already know
about.

The plugin DECLARES its fields and the platform renders them, on the plugin
detail page — the one screen that exists for every plugin whether or not it has
a frontend. A component would have survived settings too complicated to
describe, at the cost of every backend plugin carrying a frontend to draw one
text field, each looking slightly different.

The platform stores nothing. It is a form over the plugin's own /config: GET to
populate, PUT to save. So a plugin that validates before saving still does, and
one that keeps a credential keeps it where it already kept it.

A `secret` field is write-only across the boundary — never read back, rendered
as a password, and left out of the PUT when untouched so an untyped box is not
read as "clear it". The plugin reports whether it HAS one, never the value.

Eight strings across all six locales, because the key type refuses anything
else — which is the localisation work from earlier tonight doing its job.

1426 pass, tsgo clean, docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:00:04 +00:00
pastilhasandClaude Opus 5 d74e07ef08 deploy report 2026.08.24.02
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:49:29 +00:00
pastilhasandClaude Opus 5 e0d80fd3ae release 2026.08.24.02
Speech-to-text leaves the platform for the transcribe plugin. No schema, no
dependencies — a removal plus a catalogue entry.

The plan says explicitly not to install the plugin during the rollout. Which
hosts get transcription is the owner's call, made per host, and an install now
grants access to every role. Removing and installing in one unattended pass
would decide that for five machines at once.

Nothing is lost anywhere: no host had an STT endpoint configured, so the routes
this removes have never answered on any of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 03:41:15 +00:00
pastilhasandClaude Opus 5 a10757ba7c marketplace: transcribe in the catalogue
Without an entry the plugin is invisible to every server but the one it was
built on — the catalogue is what a remote host reads, and the bundled constant
is only the fallback when marketplace.officer.dev cannot be reached.

Declares no osDependencies, matching the plugin's own manifest: it reaches
whisper.cpp with fetch and shells out to nothing. An entry claiming ffmpeg would
make an install demand a binary the code never calls.

Synced. 19 in the catalogue, and the repository answers an anonymous https clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 03:39:54 +00:00
pastilhasandClaude Opus 5 e35ff5c7ad stt leaves the platform for the transcribe plugin
Speech-to-text is now plugins/transcribe — sidecar, its own config, its own
skill and a task for the file browser's context menu. The transcription logic
moved BYTE FOR BYTE: 416 lines whose every decision has a reason attached, from
the two-pass detect-then-transcribe to the 25 language priming prompts to
timeout:false. Verified identical rather than assumed.

Removed here: api/stt/, server-settings/stt.ts and its mount, POST /chat/stt,
POST /file-browser/transcribe, the FileViewer handler behind the button hidden
last release, the Settings screen, the dead useAudioRecording hook and the
orphaned locale keys across all six locales.

blobToWav stayed. It lived in the same file as the dead hook and is still used
by DictateDialog — unrelated to STT, and deleting by file rather than by use is
how that would have gone.

No permission entry to remove: STT never had one, living under server-admin,
chat and files. The registry count is unchanged and docs:check is green.

The plugin ships a task that will not appear in the menu yet — the platform
loads a plugin's skills, affordances and notifications from its own directory
but not its tasks. That loader is the remaining piece of docs/plugin-capabilities.md
and is deliberately not built here.

1426 pass, tsgo clean, 18 plugins discovered with none broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 03:29:58 +00:00
pastilhas 9ddd23c388 deploy report 2026.08.24.01 2026-08-24 03:08:02 +00:00
pastilhasandClaude Opus 5 c813eef6cc release 2026.08.24.01
One frontend file — the transcribe button hidden in the file viewer. No schema,
no dependencies, nothing to verify by query.

Also the first real firing of the rebuilt Platform Deployer commander, which is
why this is the release it gets: if the loop misbehaves the blast radius is a
button that was already invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 02:58:36 +00:00
pastilhasandClaude Opus 5 5dec221a68 file viewer: hide the transcribe button
It calls POST /file-browser/transcribe, which refuses on every server here — no
STT endpoint is configured anywhere, so it is a control that can only fail. One
of those teaches people to distrust the controls beside it, and OCR and extract
audio sit right there.

Hidden rather than deleted. Speech-to-text is leaving the platform for a plugin,
and the wiring is the useful part: the handler, the loading state and the
sibling-markdown behaviour in FileViewerProvider are all still correct, so when
the plugin exists this becomes a call to it rather than a rediscovery.

It may not come back here at all — the other route to the same job is the file
browser's context menu, which a plugin reaches with a task, and that needs no
button in this header. The comment says so, so whoever finds `false &&` knows it
is a decision rather than debugging left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 02:55:00 +00:00
pastilhas e861eb55b0 deploy report 2026.08.23.06 2026-08-24 02:26:08 +00:00
188 changed files with 8571 additions and 1926 deletions
+7 -2
View File
@@ -212,7 +212,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 3 execution, 5 admin. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
never grantable at any level). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
@@ -541,7 +541,12 @@ 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/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are
- `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 —
a namespace makes provenance readable, separate directories make it structural
+49 -43
View File
@@ -2,7 +2,7 @@
Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
Format and the two-way-sync contract: `todo/README.md`.
Format and the two-way-sync contract: `todo/README.md`. Browse and edit it at `/todos` in the app.
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
@@ -17,42 +17,48 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Urgent
- **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
**90 open, 25 closed**, across 29 themes.
**100 open, 31 closed**, across 33 themes.
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 3 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 3 | 2 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 16 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
| 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
@@ -63,13 +69,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
- `2026-08-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)
+131
View File
@@ -0,0 +1,131 @@
# 2026.08.23.06 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
rollout) and `edge-pertento-bridge` (listed under *Deliberately skipped* in `docs/servers.md`).
Every host went `e15ab230` → `2646842c` by `git merge --ff-only deploy/2026.08.23.06`. No host was
ahead, none had diverged, and no tracked file was dirty anywhere. `--force` was never used and no SQL
beyond the plan's block was run.
## Per host
### `alpha` — ✅
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
- **SQL:** both blocks applied. `RAISE EXCEPTION` did not fire — no role held a `provisioning_script`.
`DELETE FROM command_hq_sessions` removed **0 rows**; the table was already empty.
- **Verified by query** on `officer_dev`: `Developer` `grants_postgres=t grants_docker=t`; `Admin`,
`Member`, `Super Admin` all `f/f`; `provisioning_script` absent (0 columns); `dir_name` and
`reference_paths` present `NOT NULL`, `session_id` nullable.
- `bun db:push` exit 0, no prompt. Seed reported `0 seeded, 4 already present` — the Developer flags were
written by the seed's re-assert path, confirmed by re-querying after the push, not by the message.
- `bun run plugins:update` — **13/13 in 54s**, exit 0, `capabilities: pulled`. Every plugin's sidecar
restarted as part of this step: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, minesweeper,
music, offscale, photos, soulseek, transmission, vault.
- **Orphan removed:** `capabilities/affordances/officerdev/` existed and was deleted. Capabilities tree
clean afterwards — no leftover `?? extensions/`, so the new `.gitignore` had already arrived.
- `pm2 restart officer` — restart_time 72 → 73. **All 20 processes `online`.**
### `fofos` — ✅
- HEAD `e15ab230` → `2646842c`, remote HTTPS, no tracked changes.
- **Untracked file left in place:** `scripts/chk.ts`. Noted, not a reason to skip, not touched.
- **Database is `officer`, not `officer_dev`, and the URL is in `OFFICERDEV_POSTGRES_URL`** rather than
`POSTGRES_URL`. Matched the code's own precedence (`OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`,
`officer_db/src/db.ts:9`) so the migration went to the database the server actually opens.
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
and `reference_paths` `NOT NULL`, `session_id` nullable.
- `bun db:push` exit 0, no prompt, no plugin schemas on disk.
- `bun run plugins:update` — **1/1 in 7s**, exit 0, `capabilities: pulled`. `officer-gitea` sidecar
restarted by this step.
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
- `pm2 restart officer` — 25 → 26. **All 8 processes `online`.**
### `macbookpro` — ✅ (awake, with two deviations worth recording)
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
- **SQL:** both blocks applied. `psql` is at `/opt/homebrew/opt/libpq/bin/psql` and on the
non-interactive PATH, as the plan said. `DELETE` removed **0 rows**.
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
and `reference_paths` `NOT NULL`, `session_id` nullable.
- `bun db:push` exit 0, no prompt, run with `/opt/homebrew/bin` **appended** to PATH.
- `bun run plugins:update` — exit 0, but **`no plugins installed — nothing to update`**, so the step
exited before its capabilities pull. See *What I could not do* below.
- **No orphan to remove.** `~/projects/officer-suite/capabilities` is **not a git checkout** — it holds
only empty `agents/ extensions/ processes/ skills/ tasks/ tools/` and has no `affordances/` at all.
- `pm2 restart officer` (run as its own command, PATH untouched) — 12 → 13. **All 6 processes `online`.**
### `production-pertento-bridge` — ✅
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
- **Verified by query** on `officer_dev`: `Developer` `t/t`, other three `f/f`, `provisioning_script`
absent, `dir_name` and `reference_paths` `NOT NULL`, `session_id` nullable.
- `bun db:push` exit 0, no prompt. Its shell prints an nvm/`.npmrc` `globalconfig` warning before every
command — cosmetic, pre-existing, unrelated to this release.
- `bun run plugins:update` — **1/1 in 5s**, exit 0, `capabilities: pulled`. `officer-gitea` restarted.
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
- `pm2 restart officer` — 4 → 5. **All 15 processes `online`** (nine of them are this host's non-Officer
Pertento jobs — Crons, Dashboard, Tracking, Uptime — none of which were touched).
### `deadpool-rezio-bridge` — ✅ (login `br`)
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
- Database is `officer` via `OFFICERDEV_POSTGRES_URL`, same shape as `fofos`.
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
and `reference_paths` `NOT NULL`, `session_id` nullable.
- `bun db:push` exit 0, no prompt, 2 plugin schemas (email, offscale).
- `bun run plugins:update` — **2/2 in 8s**, exit 0, `capabilities: pulled`. The **email plugin advanced
`c60e3d6` → `c1c3674`** — the only plugin anywhere in this rollout that was not already current.
`officer-email` and `officer-offscale` sidecars restarted by this step.
- **No orphan**, as the plan predicted; checked anyway and `affordances/officerdev` does not exist.
- `pm2 restart officer` — 10 → 11. **All 9 processes `online`.**
## Skipped
- **`officer-dev`** — the host this rollout ran from. Nothing pulled, nothing restarted. Confirmed
current rather than assumed: HEAD is `2646842c`, the tag's commit, and a direct query shows `Developer`
`t/t` with the other three `f/f`, `provisioning_script` absent, `dir_name`/`reference_paths` present and
`session_id` nullable. Its own `officer` process is untouched and still runs the pre-restart build of
this commit — **the owner restarts it deliberately, as the last step.**
- **`edge-pertento-bridge`** — in *Deliberately skipped* since 2026-08-23. Not contacted, no retry budget
spent.
## Commanders deleted
**None, on any host.** `command_hq_sessions` held **0 rows everywhere** — every `DELETE` reported
`DELETE 0`. Each host was counted by query *before* the delete ran, so this is measured rather than
inferred. Nobody's standing job was removed by this release.
`officer-dev`'s one commander, `Platform Deployer`, had already been deleted there before the rollout
began, per the plan.
## What I could not do
- **`macbookpro` did not get the capabilities pull.** `bun run plugins:update` exits early with `no
plugins installed` and never reaches the capabilities repository, which the plan named as how the new
`.gitignore` arrives. It does not matter on that host — its capabilities directory is not a git
checkout, so there is no repository for a `.gitignore` to land in and no `affordances/` to be
duplicated. I did not invent a `git pull` the plan had not authorised. **Nothing is outstanding on that
host; the note is about the step's shape, not a missing change.**
- **The release's own "what to check" list is unrun.** Asking an agent to name its skills, switching a
panel to Swedish, and creating a commander to confirm it produces a directory are interactive checks in
a browser. This deploy agent cannot make them, so **no host has been confirmed working by use** — only
by schema query and process state.
- **`officer-dev` needs `pm2 restart officer`** to run the code it already has checked out.
## Two things worth carrying forward
**`fofos` and `deadpool-rezio-bridge` name their database differently from the other three** — the URL
lives in `OFFICERDEV_POSTGRES_URL` and the database is `officer`, not `officer_dev`. Any future plan step
that reaches for `POSTGRES_URL` alone will connect to a socket that is not there, which is what it did
here on the first attempt. The code's precedence is `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`; follow it
rather than either name.
**The seed's own output does not prove the Developer flags were written.** It prints `0 seeded, 4 already
present` on every host, because it counts inserts and the roles already existed — while the flags are
re-asserted by a separate update path. On all five hosts the flags read `f/f` immediately after the SQL
and `t/t` only after `db:push`. Nothing was wrong; the message is simply not the evidence, and the query
after the push is.
+43
View File
@@ -0,0 +1,43 @@
# 2026.08.24.01
One frontend file: the transcribe button is hidden in the file viewer's audio player.
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` is still a step
because it is cheap and it proves the database agrees with the code, but it must report no changes.
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
## What is in it
- **The transcribe button is hidden.** It calls `POST /file-browser/transcribe`, which refuses on every
server here because no STT endpoint is configured anywhere — a control that can only fail, sitting beside
OCR and extract-audio, which work. Hidden rather than deleted: speech-to-text is moving to a plugin and
the wiring stays correct for when it lands.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.01`
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
touched no schema, so a diff means that host and this tree disagree about something else.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## What to check, this release
Open a `.m4a` in the file browser. The audio player's header should show a download and a close icon and
**no transcribe icon**. An image should still offer OCR and a video should still offer extract audio — if
either of those disappeared, too much was hidden.
A stale bundle is the likely failure. Hard refresh before deciding it did not work.
No direct SQL is needed and none is authorised.
## Hosts
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. Name
it as skipped and say whether it is current.
+74
View File
@@ -0,0 +1,74 @@
# 2026.08.24.01 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
Every host went `2646842c` → `c813eef6`, the tag commit, by fast-forward. No host was dirty in tracked
files. No `bun install` was run — the plan forbade it and `bun.lock` was untouched. No direct SQL was run
and none was authorised.
| Host | Before → after | db:push | plugins | `officer` | Result |
| --- | --- | --- | --- | --- | --- |
| `alpha` | `2646842c` → `c813eef6` | applied 2 statements (see below) | 13/13 | online, 73 → 74 | ✅ |
| `fofos` | `2646842c` → `c813eef6` | applied 2 statements | 1/1 (gitea) | online, 26 → 27 | ✅ |
| `production-pertento-bridge` | `2646842c` → `c813eef6` | applied 2 statements | 1/1 (gitea) | online, 5 → 6 | ✅ |
| `deadpool-rezio-bridge` | `2646842c` → `c813eef6` | applied 2 statements | 2/2 (email, offscale) | online, 11 → 12 | ✅ |
| `macbookpro` | `2646842c` → `c813eef6` | applied 2 statements | none installed | online, 13 → 14 | ✅ |
| `officer-dev` | `c813eef6` (already) | not run | not run | **not restarted** | ⏭ skipped — self |
| `edge-pertento-bridge` | — | — | — | — | ⏭ skipped — `docs/servers.md` |
Every process on every host reported `online` after the restart: alpha 20/20, fofos 8/8,
production-pertento-bridge 15/15, deadpool 9/9, macbookpro 6/6. No process came back `errored` and none
failed to restart.
## The plan said `db:push` must report no changes. It did not, on any host.
This is a finding, and it is **not** what the plan's stop-condition describes, which is why the rollout
continued.
`bun db:push` printed `Changes applied` on all five hosts. Run again in `--strict --verbose`, it wants the
same two statements every time:
```sql
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
ALTER TABLE "music_now_playing" DROP CONSTRAINT "pk_music_now_playing";
ALTER TABLE "music_now_playing" ADD CONSTRAINT "pk_music_now_playing" PRIMARY KEY("user_id","device");
```
**The same diff appears on `officer-dev`, this release's own source host, whose database has been running
this schema for days.** That is the decisive check: the plan's condition is "that host and this tree
disagree about something else", and no host disagrees with the tree — the tree disagrees with itself,
identically, everywhere. It is the drizzle-kit mis-diff already documented in `src/databases/CLAUDE.md`
under "Composite keys", here on a named composite **primary** key rather than a unique constraint, plus a
spurious array-default diff.
Verified by query per host rather than by exit code, as the doctrine requires:
- `alpha` — `pk_music_now_playing` = `PRIMARY KEY (user_id, device)`; `reference_paths` default
`'{}'::text[]`. Both exactly what the schema code says.
- `fofos`, `production-pertento-bridge`, `deadpool-rezio-bridge`, `macbookpro` — `reference_paths`
default `'{}'::text[]`. `music_now_playing` **does not exist** on these four (`information_schema`
count 0): the music plugin is not installed there, so there is nothing for that half of the diff to
touch. Not a fault.
So no host's schema is wrong. What is wrong is that `db:push` cannot serve as the proof step this plan
wanted it to be — it reports work on a database that already matches.
**What the owner should know:** on `alpha`, where the table exists, every deploy drops and re-adds a live
table's primary key for no reason. Harmless so far and verified intact afterwards, but it is real DDL on
real data, repeated each release. The fix belongs in the schema code, not in a rollout.
## Notes
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
cannot touch it.
- `bun run plugins:update` restarts each plugin's sidecar itself. On `alpha` that is 13 sidecars bouncing
before the `officer` restart the plan asked for. Listed above as the plugin count; "restarted" is
therefore more than `officer` alone on every host but `macbookpro`.
- `officer-dev` is **current in tree** at `c813eef6` but its `officer` process was not restarted, so it is
still serving the previous bundle. Restarting it is the owner's deliberate last step.
## Not verified by this deploy
The plan's visual check — open a `.m4a` and confirm the audio player shows no transcribe icon, while an
image still offers OCR and a video still offers extract-audio. That needs a browser and a hard refresh,
and no host was checked that way. The code is deployed; that it looks right is unconfirmed.
+54
View File
@@ -0,0 +1,54 @@
# 2026.08.24.02
Speech-to-text leaves the platform. It is a plugin now — `transcribe`, in the catalogue.
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` stays a step because
it is cheap and proves the database agrees with the code, but it must report no changes.
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
## What is in it
- **`api/stt/` and `server-settings/stt.ts` are gone**, along with `POST /chat/stt`,
`POST /file-browser/transcribe`, the FileViewer handler behind the button hidden last release, the
Settings → Speech to Text screen and the orphaned locale keys in all six locales.
- **The transcription logic moved byte for byte** into `plugins/transcribe` — the two-pass
detect-then-transcribe, the 25 language priming prompts, the timeouts. Verified identical, not rewritten.
- **`transcribe` is in the catalogue**, so it can be installed from `/plugins` on any host that wants it.
## Installing the plugin is NOT part of this release
This release only removes. A host that had speech-to-text configured loses it until somebody installs the
plugin and points it at a server — and no host here had it configured, so nothing is lost anywhere.
**Do not install it as part of the rollout.** Which hosts get transcription is the owner's decision, made
per host, and an install grants access to every role. Report that the catalogue entry is available; leave
it there.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.02`
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
touched no schema, so a diff means that host and this tree disagree about something else.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## What to check, this release
**Settings should no longer offer Speech to Text.** That screen is gone, and its absence is the visible
change. If it is still there, the frontend bundle is stale — hard refresh before deciding it failed.
The audio player's header should still show download and close, and still no transcribe icon. Images
should still offer OCR and videos extract audio; those stay in the platform for now.
No direct SQL is needed and none is authorised.
## Hosts
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. It
already has this code and the plugin installed; name it as skipped and say so.
+67
View File
@@ -0,0 +1,67 @@
# 2026.08.24.02 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by plan, neither a failure.
Rolled out `deploy/2026.08.24.02` → `e0d80fd3`. Tag and `package.json` agreed. Every target moved
`c813eef6` → `e0d80fd3` — one commit, no host ahead, no host behind.
Speech-to-text left the platform. Nothing was installed in its place: the `transcribe` plugin is in the
catalogue and available, and per the plan no host was given it. That is the owner's decision, per host.
## Per host
| Host | Before | After | db:push | plugins | officer restart | processes |
| ---- | ------ | ----- | ------- | ------- | --------------- | --------- |
| `alpha` | `c813eef6` | `e0d80fd3` | no changes | 13/13 | 74 → 75 | 20/20 online |
| `fofos` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 27 → 28 | 8/8 online |
| `production-pertento-bridge` | `c813eef6` | `e0d80fd3` | no changes | 1/1 | 6 → 7 | 15/15 online |
| `deadpool-rezio-bridge` | `c813eef6` | `e0d80fd3` | no changes | 2/2 | 12 → 13 | 9/9 online |
| `macbookpro` | `c813eef6` | `e0d80fd3` | no changes | none installed | 14 → 15 | 6/6 online |
Every host ran the plan's four steps in order, all four exited 0, and none was stopped early. Checkout
paths were taken from each `officer` process's `pm_cwd`, not assumed — `alpha` resolved to
`~/officerdev/platform`, not its stale second checkout. `deadpool-rezio-bridge` was reached as `br`.
`macbookpro` was awake; its `bun` commands ran with `/opt/homebrew/bin` appended to PATH and `pm2` as a
separate command.
## Schema — verified, not assumed
The plan says no schema changed and `bun db:push` must report no changes. **It reported none on all five
hosts**, and the evidence is that it emitted no DDL: no `ALTER`, no `CREATE`, no statement of any kind
between reading the config and finishing. `[✓] Changes applied` alone was not treated as evidence —
`drizzle-kit push` prints that line whether or not it did anything, and it can exit 0 on a half-applied
change.
`stdin` was `/dev/null` on every host, so no prompt could be answered and none was. No direct SQL was
needed, none was authorised, and none was run. `--force` was not used anywhere.
The seed steps that follow `push` were no-ops everywhere, which is the same answer from a second angle:
`0 seeded, 4 already present` for roles on all five, and every transcript already recorded.
## Skipped
- **`officer-dev`** — the host this rollout ran on. Not pulled, nothing restarted. It already carries
`e0d80fd3` and has the `transcribe` plugin installed. Restarting `officer` here ends the rollout
mid-flight, so the whole host is left alone deliberately. **The owner restarts it, as the last step,
once happy with the rest.**
- **`edge-pertento-bridge`** — deliberately skipped per `docs/servers.md`, down since 2026-08-23 by the
owner's call. Not contacted at all, so it cost nothing this time.
## Notes — nothing here needs action
- **`plugins:update` bounced far more than `officer`.** Sequential per plugin, each printing
`sidecar: started`: 13 sidecars on `alpha`, 2 on `deadpool`, 1 each on `fofos` and
`production-pertento-bridge`. A report listing only `officer` under "restarted" would understate what
moved. All came back online.
- **`fofos` has one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could never
touch it, and nobody's work was discarded to ship this.
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell: `.npmrc` carries a
`globalconfig`/`prefix` setting incompatible with nvm. Cosmetic, pre-existing, unrelated to this
release, and it did not affect any step. Not fixed — that is not a rollout's job.
## What was not verified
The plan's visible check — that Settings no longer offers Speech to Text — is a **browser** check, and
this rollout has no browser. The code carrying that screen is gone from every host's tree, which is
verified; that the rendered UI reflects it is not, and a stale JS bundle would look identical to a failed
deploy. Hard refresh before concluding anything from it.
+124
View File
@@ -0,0 +1,124 @@
# 2026.08.25.01
Commanders become usable, agents get a credential that can be bounded, and a nudge finally arrives.
96 commits — the largest release so far, and most of it is one night's work on commanders driven by
watching real setup runs.
**One schema change**, additive and safe. **No dependency change** — `bun.lock` is untouched.
**Do not run `bun install`.**
**`edge-pertento-bridge` is NOT a target for this release.** It needs a schema step no other host does.
See "Edge is excluded, and why" — this is the important part of this plan.
## What is in it
- **Commanders.** The template a new commander is created with was rewritten across the night: it asks
one question per turn in the owner's own language, proves its action on a single item before arming,
writes its own `CLASSIFICATION.md` and `DISPATCHING.md` rather than editing the platform's file, and
records per step in its ledger. The Stop control works, a deleted commander stays deleted, and its
children no longer appear in `/chat`.
- **Every agent session gets a key, scoped to `notify`.** A chat can send you a notification through
the platform instead of having no sanctioned way to reach you. It is minted per session and revoked
with it.
- **Credential narrowing** — `api_keys.permissions` (an allow list) and `api_keys.excluded_kinds`
(resolved against the live registry). Applied **before** the owner bypass, which makes it the only
thing that can bound a Super Admin — the case that matters, because a commander holds the owner's
identity unattended for months. This is the schema change.
- **`notify` moved from `app` to `core`.** It was grantable, nothing granted it, and every Member's app
was silently 403'd registering its own push token. On the bridges that is four accounts.
- **The loopback notify door is closed.** `/_officer/` paths now require a producer secret the sidecar
issues at registration. It previously accepted any caller that set a header.
- **Sidecar proxy timeouts are OFF.** `TIMEOUTS_ON = false` in `create-proxy.ts`, one line to reverse.
Bun's `server.timeout()` stores one byte, so declaring 1800 seconds gave a _shorter_ timeout than
declaring nothing. More permissive than what these hosts run today — a long transcription or a film
no longer gets cut.
- **A nudge always arrives.** Three refusals removed from the nudger. Read that commit before judging
it: the old guards meant no nudge could ever fire on a machine running commanders, silently.
- **Plugin tasks appear in the file browser**, and a direct task run is direct — no modal, no job row.
- **`/todos`**, a kanban board over the repo's own `todo/` tree, owner-only.
- **Two editor fixes** — one buffer per file across panels, and the model picker reading the right id.
## The schema change
Two nullable columns on `api_keys`:
```
permissions text[] null
excluded_kinds text[] null
```
Both null means unnarrowed, which is what every existing key is — including the mobile apps'. **No
existing credential changes behaviour.** Nothing is dropped and nothing is rewritten.
`bun db:push` applies it. **Do not take the exit code as the answer** — drizzle-kit can print an error
and still exit 0. Verify on each host, against that host's own database:
```sql
select column_name from information_schema.columns
where table_name = 'api_keys' and column_name in ('permissions','excluded_kinds');
```
Two rows, or the host is not done. `permissions` alone is a half-applied push — stop and report it.
## Every host except edge
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.25.01`
2. `bun db:push` — then **run the query above**. Two rows.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It is
a laptop and may be asleep — unreachable is a skip with a note, not a failure.
## Edge is excluded, and why
`edge-pertento-bridge` came back on 2026-08-24 at `deploy/2026.08.22.02`, **288 commits behind**. It is
a deploy target again and its row is in `docs/servers.md`, but it must not be rolled out with the rest,
and the reason was verified on the host rather than inferred:
- **It has no `roles` table.** `to_regclass('roles')` is null.
- **Its two accounts hold `Super Admin` and `Developer`.**
Current schema makes `users.role` a foreign key onto `roles.name`. Every other host got `roles` created
and seeded by `2026.08.23.04`, then the foreign key added by `2026.08.23.05` — two releases, in order,
against an already-populated table. Edge would do both in one `db:push`, and the seeder runs from
`postdb:push`, which is **after** the push completes. So the constraint would be created against an
empty `roles` while `users.role` holds two values that are not in it.
That fails. Worse, it can fail and still exit 0, leaving the schema half-applied — which is exactly the
warning `2026.08.23.05` carried.
**It also missed two plans with one-off steps** that a single fast-forward does not replay:
`2026.08.23.05`'s pre-flight SQL, and `2026.08.23.06`'s orphan-affordance cleanup.
So: **name edge as skipped, do not attempt it, and do not report it as a failure.** Bringing it forward
is a watched, by-hand operation — push, seed, push again, verify the constraint — and it is being done
separately by the owner and this session, immediately after this rollout.
**One more thing about edge, for whoever reaches it later:** from `officer-dev` the name
`edge-pertento-bridge` does not resolve. It answers on **`100.64.0.15`**. `docs/servers.md` has the
address; use it.
## What to check, this release
**A commander can be created and asks its first question in your language.** That is the release, and
it is the thing most likely to reveal a stale bundle — hard refresh before deciding anything failed.
**A Member's phone can register for notifications.** `notify` is core now, so an account that was
403'ing should simply work.
**Nothing else should have changed for an ordinary signed-in owner.** Every narrowing column is null on
every existing key.
No direct SQL is needed on any host in this release, and none is authorised. The query in "The schema
change" is a read.
## Hosts
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It is already on this code.
- **`edge-pertento-bridge`** — skipped, see above. Not a failure.
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
- **`macbookpro`** — a target if it is awake.
+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.
+69 -10
View File
@@ -22,20 +22,79 @@ Two details from that report do most of the diagnostic work:
Do not re-investigate these without new evidence.
| Candidate | Why it is out |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. |
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
| Candidate | Why it is out |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. |
| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. |
| ~~Two Monaco editors sharing a model~~ **— WRONG, see below** | This asked which OTHER component mounts Monaco and answered `EditorRenderer` (still true: no `path`, so `inmemory://model/N`, no collision). It never asked whether the **code editor collides with ITSELF**. It does — the panel is addable to a dashboard twice. |
| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. |
That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic
`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about.
One thing to check first, because it is recent and it is ours: `2b70a570` gave `<Editor>` a real `path`,
so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share
one model. That should be an improvement, and it is a change in exactly the layer that turns out to be
involved.
## Two code-editor panels share one model and two buffers (2026-08-25)
The suspicion recorded here — `2b70a570` giving `<Editor>` a real `path`, so models are keyed by file URI
**globally** rather than per mount — is correct, and it is worse than "should be an improvement". Verified
by reading `@monaco-editor/react@4.7.0`'s built source, not inferred:
- **Model lookup is `getModel(Uri.parse(path)) || createModel(value, …)`.** An existing model for that path
wins and the second mount's `value` is **ignored at creation**. So the second panel to open a file adopts
the first panel's buffer.
- **The value-sync effect then overwrites it**, because `value !== editor.getValue()`:
`executeEdits('', [{ range: getFullModelRange(), text: value }])`.
- **The guard that suppresses `onChange` during that write is per-INSTANCE.** The package sets an internal
flag around its own `executeEdits` and checks it inside `onDidChangeModelContent`. Panel B's flag is not
panel A's. So B's programmatic write **fires A's `onChange`** with B's content.
- **Unmount disposes the shared model.** `keepCurrentModel` defaults to `false` and we never set it, so the
cleanup calls `editor.getModel()?.dispose()`. And `CodeEditor` mounts `<Editor key={activeFile.path}>`,
so this runs on every **tab switch**, not only on panel close — switching tabs in one panel disposes the
model another panel is still displaying.
The other half is ours: `useEditorState` holds `files` in a plain `useState`, so it is **per panel
instance**. Its own comment says so — "this app also mounts as a dashboard panel, and a dashboard can hold
two of them". One shared model, two independent `content` values, and an `onChange` that crosses between
them. `CodeEditor`'s handler does `setContent(activeFile.path, value)`, so panel A's buffer silently becomes
panel B's content and `isDirty` flips with no keystroke — dirty tab, content the user never typed, disk
untouched, fixed by reopening. That is the reported symptom's exact shape.
**What this does NOT yet explain is how JOB OUTPUT becomes one of those `value`s.** Nothing in the app
writes job output into `useEditorState` — `openFile`/`setContent`/`reloadFile` are called only from
`CodeEditor.tsx`, and the task log renders through shiki. So this is a real defect that produces the right
shape, and it is not proven to be the reported instance. **The trap is still the thing that names the
text's source.** Do not delete it on the strength of this section.
## Fixed 2026-08-25: the buffer is shared, because the model already was
`useEditorState` now keys buffers by path in one store shared across panels, and keeps only the tab set and
the active path per panel. The rule that matters: **`openFile` discards the content it is handed when the
file is already open somewhere else.** That mirrors what Monaco does with the model, and it is what removes
the divergence — with both panels reading one buffer there is never a `value` for the sync effect to
reconcile, so the `executeEdits` never runs and the cross-panel `onChange` never fires.
A buffer outlives the tab that opened it. `holders` is the set of panels with the file open, and the entry
is dropped when the last one closes, so closing a tab in one panel cannot take unsaved work out from under
another. Closing the last one still discards unsaved edits, exactly as before.
`<Editor keepCurrentModel>` is the other half, and it was needed under any fix: the default disposes the
shared model on unmount, and `key={activeFile.path}` makes that every **tab switch**, not just a panel
close. The cost is one retained model per file opened in a session.
Two things worth knowing:
- **Writes go through `queryClient.setQueryData`, not `useGlobal`'s setter.** `useGlobal` applies a
functional update to the `data` captured in render rather than to what is in the cache now. With one
writer that is invisible; this slot has one writer per panel, which is the whole scenario.
- **The trap can now fire legitimately.** Two panels on one file share a model, so a large paste in one
fires the other's `onChange` while it is unfocused — which is what `reportSuspiciousEdit` looks for. A
report whose `stack` runs through the editor's own `onChange` with another panel open is that, not the
bug. This is the reason to keep reading the `stack` rather than the count.
Pinned by `useEditorState.test.ts` (9 tests): adoption, cross-panel edit, close-with-holders, drop on last
close, release on unmount, and that the tab set stays per panel.
**Still not closed.** This removes the mechanism, not the mystery: where the log text came from is still
unknown, and nothing found so far writes job output into an editor buffer. Keep the trap.
## When it is reported
+57 -1
View File
@@ -1,6 +1,10 @@
# Localization
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
**Status: BUILT and shipping six locales** — en-GB, pt-PT, es-ES, de-DE, fr-FR, sv-SE. The mechanism
is done; what remains is coverage, which is the count-taking below.
**To add a seventh language, read "Adding a locale" at the bottom and nothing else.** It is seven
files and the order does not matter, but missing one is silent — see the failure it is written from.
This is an execution plan. It is written to be handed to an agent who has not had the conversation
that produced it, so it states the decisions and the reasons for them, not just the steps.
@@ -412,3 +416,55 @@ else will ever notice.
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
---
## Adding a locale
Seven files. Six are mechanical; the seventh is the one that gets missed.
**Three catalogues, one per workspace.** Copy `en-GB.json` beside itself and translate the values,
keeping every key:
```
src/apps/officer-web/locales/<code>.json app.* the shell, screens, settings
src/workspaces/officerdev/src/locales/<code>.json panel.* the windowed apps
src/workspaces/components/locales/<code>.json ui.* shared components
```
**Three registrations, one per catalogue.** Each is an `import` plus a line in a map:
```
src/apps/officer-web/lib/catalogues.ts
src/workspaces/officerdev/src/locales/index.ts
src/workspaces/components/locales/index.ts
```
**And the picker**, which is the one that gets missed the other way round:
```
src/apps/officer-web/lib/locales.ts SHIPPED_LOCALES
```
### Why this is spelled out rather than left to reading the code
es-ES, de-DE and fr-FR once shipped translated, in perfect key parity, listed in the picker — and
completely dead. All three registration files still imported only en-GB and pt-PT, so the merge
produced three empty catalogues and the UI rendered raw keys in three of five languages.
Nothing caught it. The parity tests compare the JSON files against each other and every file was
correct; a locale that is never imported is invisible to them by construction. The picker was right.
Four edits in four files with no single place that knew all four had happened.
### What checks you now
Run `bun test` and it is settled. Two things stand where nothing stood:
- `src/apps/officer-web/lib/catalogues.test.ts` asserts every locale in `SHIPPED_LOCALES` resolves to
a populated catalogue in all three namespaces, and matches en-GB's size. A locale in the picker but
not imported fails here. So does one imported but empty.
- The three `parity.test.ts` files read their own directory rather than listing locales by hand, so a
new `<code>.json` is checked the moment it exists — for missing keys AND extra ones, because
`findMissingKeys` alone reports half of a rename.
Then `bunx tsgo` and `bunx prettier --write` on what you touched, as everywhere else.
+23 -6
View File
@@ -1,6 +1,7 @@
# A plugin's capabilities are LOADED, never copied
**Status: designed, not built.** What exists today is the copying this replaces.
**Status: built.** Skills, affordances and notification catalogues landed in `2026.08.23.06`; tasks — the
read-write one, and the last piece — landed on 2026-08-24. The four publishers are gone.
## The problem, as it is on disk right now
@@ -32,8 +33,8 @@ plugins/<name>/ the plugin's own repository, cloned on instal
```
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
which is the whole reason this beats scoping the paths. A namespace makes provenance *readable*; separate
directories make it *structural*.
which is the whole reason this beats scoping the paths. A namespace makes provenance _readable_; separate
directories make it _structural_.
Four consequences, and each of them is a bug that stops existing:
@@ -64,6 +65,21 @@ silently writes into a directory the next `plugins:update` overwrites.
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
search every source rather than assuming one root.
**How it went in, since it turned out to be one function rather than several.** Every path that runs a
task — the file browser's context menu, both runner sockets, the job queue, the pipeline executor —
resolves it through `getTaskByDirName` and then reads `implementation` off the record. So the sources go
in there and in `listTasks`, and a plugin's task becomes runnable everywhere at once with its identity
unchanged: still a directory name, still what `pipeline_jobs` stores.
The record carries `plugin` — the app name, or null for the owner's own. That one field does the rest:
`updateTask` and `deleteTask` throw on it, `DELETE /api/tasks/:name` answers 409 naming the plugin, and
the capability screen replaces the edit and delete controls with a line saying whose it is. Without the
delete guard the old code was quietly a no-op — `rm -rf` on a path in the owner's store that does not
exist, then `{ ok: true }`, and the task still in the menu.
`getTaskByDirName` also refuses a name containing a separator. It always joined a caller-supplied string
onto a path; searching several roots is the moment that stops being one directory nobody could escape.
**The four publishers are deleted**, along with their install and uninstall wiring:
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
last was written on 2026-08-23 and is superseded before it ever shipped.
@@ -87,6 +103,7 @@ what happens today and needs no change. Scoping them is a separate question and
force it either way.
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
task with the same directory name, and the loader has to break that tie — first-come, as the items store
has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin
ships a task; not before.
task with the same directory name. The tie-break shipped as first-come with the owner's store searched
first, which settles the case that matters — a plugin cannot take over a name its owner is already
using — and leaves two plugins colliding with each other resolved by discovery order, which is arbitrary.
Worth revisiting when a second plugin ships a task; not before.
+6 -8
View File
@@ -30,6 +30,7 @@ Seven, all running.
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
@@ -40,15 +41,12 @@ reachable from inside only.
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
| Host | Since | Why |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
None today. `edge-pertento-bridge` was here from 2026-08-23 and came back up on 2026-08-24; it is a
deploy target again and its row is in the table above.
Its row, kept so restoring it is one move:
```
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
```
It was down long enough to matter, so expect it to be behind: when it returned it was 215 commits back at
`2026.08.22.02`, with no `roles` table and no commanders. A host coming off this list needs its schema
brought forward before it is treated as ordinary.
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.23.06",
"version": "2026.08.26.02",
"private": true,
"type": "module",
"workspaces": [
+105
View File
@@ -0,0 +1,105 @@
/*
Give a credential to every commander that predates them.
A commander created before `credential.ts` existed has no `officer.env` and no key row, so it cannot
reach the platform at all — which is the state that made the first one curl the whisper container
directly. Deleting and recreating it would fix that and throw away the conversation, which is the one
thing a commander is: a long-running agreement about a job.
So this is additive and re-runnable:
- a commander that already has `officer.env` is left alone, whole;
- `CLAUDE.md` is APPENDED to, never rewritten — it is a living document its commander has been
extending, and the whole point of not recreating it is that it survives;
- the key is named `commander:<dirName>`, so it revokes with the commander like any other.
Run it after a deploy that introduces credentials, on each host:
bun run --env-file=.env scripts/backfill-commander-credentials.ts
*/
import { readFileSync, existsSync, appendFileSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { getUsers, listCommandHqSessions } from 'officerdb';
import { writeCommanderCredential, commanderEnvPath } from '../src/servers/api/command-hq/credential';
import { credentialSection, notifySection } from '../src/servers/api/command-hq/directory';
import { getCommanderDir } from '../src/servers/data-path';
import { lookupOsUser } from '../src/servers/os-user';
/** Best effort, and only for a member: the owner already owns everything under DATA_PATH. */
async function giveToAccount(osUser: string | null, path: string): Promise<void> {
if (!osUser) return;
const ids = await lookupOsUser(osUser);
if (!ids) return;
Bun.spawnSync(['sudo', '-n', 'chown', `${ids.uid}:${ids.gid}`, path]);
}
// Per user, because `listCommandHqSessions` is scoped by one — which is the right shape everywhere else
// and only awkward here. There are few accounts and few commanders; this runs once per deploy.
const rows: Array<{
dirName: string | null;
name: string | null;
userId: number;
email: string;
osUser: string | null;
}> = [];
for (const user of await getUsers()) {
for (const row of await listCommandHqSessions(user.id)) {
rows.push({ dirName: row.dirName, name: row.name, userId: user.id, email: user.email, osUser: user.osUser });
}
}
if (rows.length === 0) console.log('no commanders on this host');
for (const row of rows) {
const label = `${row.dirName} (${row.name})`;
if (!row.dirName) {
console.log(`skip ${label} — no directory name`);
continue;
}
const dir = getCommanderDir(row.email, row.dirName);
if (!existsSync(dir)) {
console.log(`skip ${label} — no directory on disk`);
continue;
}
const claudeMd = join(dir, 'CLAUDE.md');
/**
* Append a section only when it is not already described.
*
* The marker is a string the section itself contains, so a commander whose CLAUDE.md was hand-edited to
* describe the same thing does not end up with it twice.
*/
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;
};
const envPath = commanderEnvPath(row.email, row.dirName);
if (!existsSync(envPath)) {
const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName });
chmodSync(path, 0o600);
await giveToAccount(row.osUser, path);
await appendIfMissing('officer.env', credentialSection(path));
console.log(`MINT ${label} — ${path}`);
} else {
console.log(`ok ${label} — already has a credential`);
}
/*
Separately, because it arrived later and the commanders that need it most already have a credential.
Without it a commander does not know Officer HAS a notification route, so it reaches for whatever its
harness offers — a different channel, with its own rules about when it may interrupt.
*/
if (await appendIfMissing('/api/notify/_officer/notify', notifySection())) {
console.log(`NOTIFY ${label} — told how to reach the owner`);
}
}
process.exit(0);
+46 -9
View File
@@ -6,13 +6,25 @@
* bun run notify:test --user 2 # someone else
* bun run notify:test --code officer/core:job --id 412
*
* Talks to the sidecar over loopback, which is how every other producer reaches it — so this exercises
* the real path rather than a special one. It reports what the channels actually did, because the thing
* that wastes a day here is that a send with nothing configured returns success and delivers nothing.
* Goes through the PLATFORM, with a key it mints and revokes for the one send — the same door an app or
* a commander uses, so this exercises the real path rather than a special one. It reports what the
* channels actually did, because the thing that wastes a day here is that a send with nothing configured
* returns success and delivers nothing.
*
* It used to POST to the sidecar directly on loopback. That stopped working on 2026-08-25, when the
* sidecar began requiring proof that the platform is the caller — anything on the machine could otherwise
* notify any user, which on a box running agents is anything with a shell. The secret lives only in the
* server's memory, so a CLI cannot present it and should not be able to.
*
* Minting a key is legitimate HERE and nowhere else: this runs as the owner, on the owner's machine, with
* the database open in front of it. Anything that cannot already do that has no business sending either.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../src/servers/data-path';
import { mintApiKey } from '../src/servers/auth-token';
import { createApiKey, revokeApiKeysByName } from 'officerdb';
import { PORT, OFFICER_API_URL } from '../src/servers/officer-url.mjs';
const arg = (name: string): string | undefined => {
const i = process.argv.indexOf(`--${name}`);
@@ -69,12 +81,37 @@ const body = {
console.log(`[notify:test] sending ${JSON.stringify(body)}`);
const res = await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
// A key for this one send, revoked immediately afterwards whatever happens. Named so a leftover from a
// killed run is obvious in the key list rather than looking like something a person made.
const KEY_NAME = 'notify:test (temporary)';
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
const { secret, keyHash, prefix } = mintApiKey();
await createApiKey({ userId, name: KEY_NAME, keyHash, prefix });
const publicHost = (() => {
try {
return process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL.replace(/^"|"$/g, '')).host : undefined;
} catch {
return undefined;
}
})();
let res: Response;
try {
res = await fetch(`${OFFICER_API_URL}/api/notify/_officer/notify`, {
method: 'POST',
headers: {
'content-type': 'application/json',
Authorization: `Bearer ${secret}`,
// Origin validation matches Host against PUBLIC_URL, so a bare loopback request is refused.
Host: publicHost ?? `127.0.0.1:${PORT}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
} finally {
await revokeApiKeysByName(userId, KEY_NAME).catch(() => {});
}
const out = (await res.json()) as { ok?: boolean; error?: string; results?: Array<Record<string, unknown>> };
+9 -3
View File
@@ -81,6 +81,12 @@ LAST_SKIPPED=()
# distribution's default alongside it. It stays in core because anything that
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
#
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
# moment a file lands instead of polling for it. Without it a watcher can only
# ask "is there anything new?" on a timer, which is both slower and more work —
# and the commander has no way to know the tool is missing until it tries. macOS
# has no equivalent in the package sets here; a commander there polls.
pkgs_core() {
case "$PM" in
apt)
@@ -103,17 +109,17 @@ pkgs_core() {
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
poppler-utils radicale fail2ban unattended-upgrades
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
poppler radicale fail2ban
inotify-tools poppler radicale fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
poppler-utils radicale fail2ban
inotify-tools poppler-utils radicale fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
+50 -9
View File
@@ -466,32 +466,73 @@ disable_sleep() {
# systemd-networkd actually manages the network — a server or cloud image, via
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
#
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
# but the wait-online unit is still enabled and waits for a link that will never
# be configured. It gives up after its full timeout, on every boot.
# Where NetworkManager owns the network instead, systemd-networkd configures
# nothing, but the wait-online unit is still enabled and waits for a link that
# will never come up. It gives up after its full timeout, on every boot.
#
# So the fix is masking one unit, and only on the second stack. Do NOT
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
# networkd-managed box that brings it up with no network on the next boot, no
# ssh, and nothing but the provider's rescue console.
#
# ── "is networkd running" is the wrong question ──
#
# It was the test here until 2026-08-24, and it is why this section did nothing
# on the machine it was written for. Both stacks can be ACTIVE at once: on the
# dev box NetworkManager owned enp1s0 and wlp2s0, systemd-networkd was up and
# managing not one of eighty links, and wait-online still burned its full two
# minutes on every boot — `Timeout occurred while waiting for network
# connectivity`, at 18:31:05 to 18:33:05, in the journal.
#
# What matters is whether networkd CONFIGURED anything, which `networkctl` will
# say. A link it owns reads `configured`; a link it is ignoring reads
# `unmanaged`. If every link is unmanaged, the wait cannot ever be satisfied and
# masking it is safe.
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
# Reported to the operator, so it has to describe the machine rather than pick a
# winner: both daemons running with networkd idle is the exact case this section
# used to get wrong, and printing "NetworkManager" for it hid that.
network_manager_name() {
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
local nm=no nd=no
systemctl is-active --quiet NetworkManager.service 2>/dev/null && nm=yes
systemctl is-active --quiet systemd-networkd.service 2>/dev/null && nd=yes
if [[ $nm == yes && $nd == yes ]]; then
if networkd_configures_a_link; then
echo "both — networkd configures at least one link"
else
echo "NetworkManager (networkd is up but configures nothing)"
fi
elif [[ $nm == yes ]]; then
echo "NetworkManager"
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
elif [[ $nd == yes ]]; then
echo "systemd-networkd"
else
echo "neither — unclear"
fi
}
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
# Anything else, including "cannot tell", is left alone.
# Does networkd actually configure a link on this machine?
#
# `networkctl list --no-legend` puts the SETUP state last on each line. Anything
# other than unmanaged/pending/n-a means networkd owns that link and the wait is
# real work. Missing networkctl counts as "yes, it does" — an unanswerable
# question must not be read as permission to mask.
networkd_configures_a_link() {
command -v networkctl &>/dev/null || return 0
networkctl list --no-legend 2>/dev/null | awk '{ print $NF }' |
grep -qvE '^(unmanaged|pending|n/a)$'
}
# Is the wait actually pointless here? NetworkManager in charge, and networkd
# either absent or configuring nothing. Anything else, including "cannot tell",
# is left alone.
wait_online_is_spurious() {
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
systemctl is-active --quiet NetworkManager.service 2>/dev/null || return 1
systemctl is-active --quiet systemd-networkd.service 2>/dev/null || return 0
! networkd_configures_a_link
}
# What that unit actually cost on this boot, straight from systemd's own
+9 -5
View File
@@ -1170,18 +1170,22 @@ elif ! skip; then
echo ""
info "Boot hang — a unit that can add two minutes to every boot"
echo " On a machine where NetworkManager owns the network, systemd-networkd"
echo " runs nothing — but systemd-networkd-wait-online is still enabled, and"
echo " waits for a link that will never come up. It gives up after its full"
echo " timeout, every single boot."
echo " configures nothing — but systemd-networkd-wait-online is still"
echo " enabled, and waits for a link that will never come up. It gives up"
echo " after its full timeout, every single boot."
echo ""
echo " Note both daemons can be running at once. What decides it is whether"
echo " networkd CONFIGURED a link, not whether it is up."
echo ""
echo " network managed by: $(network_manager_name)"
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
if ! wait_online_is_spurious; then
echo ""
echo " systemd-networkd is live here, so that unit is doing real work."
echo " systemd-networkd configures a link here, so that unit is doing"
echo " real work."
echo " Masking it would be how this machine comes up with no network."
SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
SUMMARY+=("Boot hang: nothing to fix — networkd configures a link")
else
echo ""
if is_role dev; then
+6 -136
View File
@@ -1,140 +1,10 @@
/**
* Regenerate `TODO.md` — the index over `todo/`.
* Regenerate `TODO.md` — the index over `todo/`. `bun run todo:index` after any change to the tree.
*
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
* it describes.
* The logic lives in `src/servers/api/todos/todo-tree.ts`, shared with the `/api/todos` router so the
* board and this CLI cannot disagree about the format; this file is only the command.
*/
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { writeIndex } from '../src/servers/api/todos/todo-tree';
const ROOT = new URL('..', import.meta.url).pathname;
const OUT = join(ROOT, 'todo');
export type Item = {
id: string;
title: string;
theme: string;
status: string;
created: string;
/** `urgent` or empty — see todo/README.md for why there is no scale. */
priority: string;
due: string;
order: number;
file: string;
};
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
export function frontmatter(text: string): Record<string, string> {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return {};
const out: Record<string, string> = {};
for (const line of text.slice(4, end).split('\n')) {
const m = line.match(/^([a-z_]+):\s*(.*)$/);
if (!m || !m[1]) continue;
const raw = (m[2] ?? '').trim();
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
}
return out;
}
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
const themes = readdirSync(OUT)
.filter((d) => statSync(join(OUT, d)).isDirectory())
.sort()
.map((theme) => {
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
const items = files
.filter((f) => f !== '_index.md')
.map((f) => {
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
return {
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
title: fm.title ?? f,
theme,
status: fm.status ?? 'open',
created: fm.created ?? '',
priority: fm.priority ?? '',
due: fm.due ?? '',
order: Number(fm.order ?? 0),
file: `todo/${theme}/${f}`,
};
})
.sort((a, b) => a.order - b.order);
return { theme, heading: meta.heading ?? theme, items };
});
return { themes };
}
const HEADER = `# TODO — the index
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
Format and the two-way-sync contract: \`todo/README.md\`.
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
`;
function render(): string {
const { themes } = readTree();
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
const rows = themes
.filter((t) => t.items.length)
.map((t) => {
const o = t.items.filter((i) => i.status !== 'done').length;
const d = t.items.length - o;
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
});
const recent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done')
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, 10)
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
// whole split risks. List them explicitly rather than letting them age out of view.
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
// by theme, is an item nobody sees on the day it matters.
const urgent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
return [
HEADER,
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
`## Themes\n`,
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
`| Theme | Open | Closed |`,
`| --- | --- | --- |`,
...rows,
``,
`## Records — no items, kept for the reasoning\n`,
...notes,
``,
`## Newest ten\n`,
...recent,
``,
].join('\n');
}
if (import.meta.main) {
writeFileSync(join(ROOT, 'TODO.md'), render());
console.log('TODO.md regenerated');
}
writeIndex();
console.log('TODO.md regenerated');
+1
View File
@@ -107,6 +107,7 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/todos" element={<Dashboard.TodosScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -24,6 +24,8 @@ export type CapabilityDetail = CapabilitySummary & {
rawFrontmatter: string;
filePath: string;
chatSessionId: string | null;
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
plugin?: string | null;
};
type CapabilityListProps = {
@@ -538,7 +540,17 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<span className="text-sm font-medium text-duck-dark/70 flex-1">
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
</span>
{detail && (
{/*
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
and the delete refuses server-side (409). Say whose it is instead of showing two controls
that cannot work.
*/}
{detail?.plugin ? (
<span className="text-xs text-duck-dark/40">
{t('capability.fromPlugin', { plugin: detail.plugin })}
</span>
) : detail ? (
<>
<button
onClick={() => setEditing(!editing)}
@@ -553,7 +565,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
</button>
</>
)}
) : null}
</div>
<div className="overflow-y-auto flex-1 p-6">
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { WorkspaceView, useCommanderFilesPanel } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
@@ -10,6 +10,7 @@ import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
export const CommandHqScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
const ephemeral = useCommanderFilesPanel();
/*
Move a saved three-column layout onto the two-column one.
@@ -26,6 +27,9 @@ export const CommandHqScreen = () => {
return (
<div className="h-full w-full pt-2">
{/* The commander's own file browser is a layout of its own, rendered beside this one — it never
enters `workspace.value`, so this screen stays locked and the allow-list does not have to know
about it. Same trade `/files` makes for the file viewer. */}
<WorkspaceView
workspace={workspace}
locked
@@ -33,6 +37,7 @@ export const CommandHqScreen = () => {
allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'],
fallback: 'command-hq-detail',
}}
ephemeral={ephemeral}
/>
</div>
);
@@ -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
@@ -9,7 +9,6 @@ import {
LayoutGrid,
Lock,
Mail,
Mic,
ScanText,
ShieldCheck,
Sparkles,
@@ -156,7 +155,6 @@ const SETTINGS_ITEMS: SettingsItem[] = [
},
// System — SystemSettings.tsx
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
{
id: 'set:maintenance',
@@ -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 (
@@ -1,107 +0,0 @@
import type { FormEvent } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type SttConfig = {
url: string;
};
export const STTSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { data: config, isLoading } = useQuery({
queryKey: ['STT_CONFIG'],
queryFn: () => client.get<SttConfig | null>('/server-settings/stt'),
});
const [url, setUrl] = useState('http://localhost:64201');
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
useEffect(() => {
if (!config) return;
setUrl(config.url);
}, [config]);
const handleSave = async (ev?: FormEvent) => {
ev?.preventDefault();
if (isSaving) return;
setIsSaving(true);
try {
await client.put('/server-settings/stt', { url });
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
toast.success(t('settings.server.stt.saved'));
} catch {
toast.error(t('settings.server.stt.saveFailed'));
} finally {
setIsSaving(false);
}
};
const handleTest = async () => {
if (isTesting) return;
setIsTesting(true);
try {
const res = await client.post<{ success?: boolean; error?: string }>('/server-settings/stt/test', { url });
if (res.error) {
toast.error(res.error);
} else {
toast.success(t('settings.server.stt.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTesting(false);
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64201"
value={url}
onChange={(ev) => setUrl(ev.target.value)}
/>
</Label>
<div className="flex items-center gap-2">
<Button
type="submit"
disabled={isSaving}
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={isTesting}
className="h-11 cursor-pointer disabled:opacity-50"
>
{isTesting ? 'Testing...' : 'Test'}
</Button>
</div>
</form>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { Bot, Hammer, Mail, Mic, ScanText, Settings, Volume2 } from 'lucide-react';
import { Bot, Hammer, Mail, ScanText, Settings, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
@@ -7,7 +7,6 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
import { SMTPSection } from './ServerSettings/SMTPSection';
import { MaintenanceSection } from './ServerSettings/MaintenanceSection';
import { TTSSection } from './ServerSettings/TTSSection';
import { STTSection } from './ServerSettings/STTSection';
import { OCRSection } from './ServerSettings/OCRSection';
const groups: SettingsSectionGroup[] = [
@@ -22,13 +21,6 @@ const groups: SettingsSectionGroup[] = [
descriptionKey: 'settings.sections.tts.description',
content: <TTSSection />,
},
{
key: 'stt',
icon: Mic,
titleKey: 'settings.sections.stt.label',
descriptionKey: 'settings.sections.stt.description',
content: <STTSection />,
},
{
key: 'ocr',
icon: ScanText,
@@ -0,0 +1,12 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
export const TodosScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
};
@@ -0,0 +1,8 @@
import type { LayoutNode } from 'officerdev';
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'todo-board',
appType: 'todo-board',
};
@@ -0,0 +1 @@
export * from './TodosScreen';
@@ -23,3 +23,4 @@ export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
+4 -13
View File
@@ -145,10 +145,6 @@
"label": "Text-zu-Sprache",
"description": "TTS-Anbieter und Stimme"
},
"stt": {
"label": "Sprache-zu-Text",
"description": "Whisper-Server-URL"
},
"ocr": {
"label": "OCR",
"description": "Vision-Modell für Textextraktion"
@@ -234,12 +230,6 @@
"saveFailed": "OCR-Einstellungen konnten nicht gespeichert werden",
"reachable": "Vision-Modell-Server ist erreichbar"
},
"stt": {
"serverUrl": "Whisper-Server-URL",
"saved": "STT-Einstellungen gespeichert",
"saveFailed": "STT-Einstellungen konnten nicht gespeichert werden",
"reachable": "Whisper-Server ist erreichbar"
},
"tts": {
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI usw.)",
"elevenLabs": "ElevenLabs",
@@ -488,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}})",
@@ -504,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…",
@@ -692,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Keine Treffer",
"emptyFile": "Leere Datei",
"fromPlugin": "Gehört zum Plugin {{plugin}} — schreibgeschützt",
"createFailed": "{{kind}} konnte nicht erstellt werden",
"deleteFailed": "{{kind}} konnte nicht gelöscht werden",
"searchPlaceholder": "{{kind}} durchsuchen…",
+4 -13
View File
@@ -145,10 +145,6 @@
"label": "Text to Speech",
"description": "TTS provider and voice"
},
"stt": {
"label": "Speech to Text",
"description": "Whisper server URL"
},
"ocr": {
"label": "OCR",
"description": "Vision model for text extraction"
@@ -234,12 +230,6 @@
"saveFailed": "Failed to save OCR settings",
"reachable": "Vision model server is reachable"
},
"stt": {
"serverUrl": "Whisper Server URL",
"saved": "STT settings saved",
"saveFailed": "Failed to save STT settings",
"reachable": "Whisper server is reachable"
},
"tts": {
"openaiCompatible": "OpenAI-compatible (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
@@ -488,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}})",
@@ -504,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…",
@@ -692,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "No matches",
"emptyFile": "Empty file",
"fromPlugin": "Ships with the {{plugin}} plugin — read-only",
"createFailed": "Could not create the {{kind}}",
"deleteFailed": "Could not delete the {{kind}}",
"searchPlaceholder": "Search {{kind}}…",
+4 -13
View File
@@ -145,10 +145,6 @@
"label": "Texto a voz",
"description": "Proveedor de TTS y voz"
},
"stt": {
"label": "Voz a texto",
"description": "URL del servidor Whisper"
},
"ocr": {
"label": "OCR",
"description": "Modelo de visión para extracción de texto"
@@ -234,12 +230,6 @@
"saveFailed": "No se pudieron guardar los ajustes de OCR",
"reachable": "El servidor del modelo de visión está accesible"
},
"stt": {
"serverUrl": "URL del servidor Whisper",
"saved": "Ajustes de STT guardados",
"saveFailed": "No se pudieron guardar los ajustes de STT",
"reachable": "El servidor Whisper está accesible"
},
"tts": {
"openaiCompatible": "Compatible con OpenAI (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
@@ -488,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}})",
@@ -504,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…",
@@ -692,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Sin coincidencias",
"emptyFile": "Archivo vacío",
"fromPlugin": "Viene del plugin {{plugin}} — solo lectura",
"createFailed": "No se pudo crear el {{kind}}",
"deleteFailed": "No se pudo eliminar el {{kind}}",
"searchPlaceholder": "Buscar {{kind}}…",
+4 -13
View File
@@ -145,10 +145,6 @@
"label": "Synthèse vocale",
"description": "Fournisseur et voix pour la synthèse vocale"
},
"stt": {
"label": "Reconnaissance vocale",
"description": "URL du serveur Whisper"
},
"ocr": {
"label": "OCR",
"description": "Modèle de vision pour l'extraction de texte"
@@ -234,12 +230,6 @@
"saveFailed": "Échec de l'enregistrement des paramètres OCR",
"reachable": "Le serveur du modèle de vision est accessible"
},
"stt": {
"serverUrl": "URL du serveur Whisper",
"saved": "Paramètres de reconnaissance vocale enregistrés",
"saveFailed": "Échec de l'enregistrement des paramètres de reconnaissance vocale",
"reachable": "Le serveur Whisper est accessible"
},
"tts": {
"openaiCompatible": "Compatible OpenAI (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
@@ -488,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}})",
@@ -504,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 à…",
@@ -692,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Aucun résultat",
"emptyFile": "Fichier vide",
"fromPlugin": "Fourni par le plugin {{plugin}} — lecture seule",
"createFailed": "Impossible de créer le {{kind}}",
"deleteFailed": "Impossible de supprimer le {{kind}}",
"searchPlaceholder": "Rechercher {{kind}}…",
+4 -13
View File
@@ -145,10 +145,6 @@
"label": "Texto para fala",
"description": "Fornecedor de TTS e voz"
},
"stt": {
"label": "Fala para texto",
"description": "URL do servidor Whisper"
},
"ocr": {
"label": "OCR",
"description": "Modelo de visão para extração de texto"
@@ -234,12 +230,6 @@
"saveFailed": "Falha ao guardar as definições de OCR",
"reachable": "O servidor do modelo de visão está acessível"
},
"stt": {
"serverUrl": "URL do servidor Whisper",
"saved": "Definições de STT guardadas",
"saveFailed": "Falha ao guardar as definições de STT",
"reachable": "O servidor Whisper está acessível"
},
"tts": {
"openaiCompatible": "Compatível com OpenAI (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
@@ -488,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}})",
@@ -504,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…",
@@ -692,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Sem correspondências",
"emptyFile": "Ficheiro vazio",
"fromPlugin": "Vem do plugin {{plugin}} — só de leitura",
"createFailed": "Não foi possível criar o {{kind}}",
"deleteFailed": "Não foi possível eliminar o {{kind}}",
"searchPlaceholder": "Pesquisar {{kind}}…",
+4 -13
View File
@@ -145,10 +145,6 @@
"label": "Text till tal",
"description": "TTS-leverantör och röst"
},
"stt": {
"label": "Tal till text",
"description": "Whisper-serverns URL"
},
"ocr": {
"label": "OCR",
"description": "Synmodell för textextrahering"
@@ -234,12 +230,6 @@
"saveFailed": "Det gick inte att spara OCR-inställningarna",
"reachable": "Synmodellsservern är nåbar"
},
"stt": {
"serverUrl": "URL till Whisper-server",
"saved": "STT-inställningarna sparade",
"saveFailed": "Det gick inte att spara STT-inställningarna",
"reachable": "Whisper-servern är nåbar"
},
"tts": {
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI, m.fl.)",
"elevenLabs": "ElevenLabs",
@@ -488,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}})",
@@ -504,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…",
@@ -692,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Inga träffar",
"emptyFile": "Tom fil",
"fromPlugin": "Följer med plugin-programmet {{plugin}} — skrivskyddad",
"createFailed": "Det gick inte att skapa {{kind}}",
"deleteFailed": "Det gick inte att ta bort {{kind}}",
"searchPlaceholder": "Sök {{kind}}…",
+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',
},
@@ -3,6 +3,7 @@ export {
createApiKey,
listApiKeys,
revokeApiKey,
revokeApiKeysByName,
deleteApiKey,
touchApiKey,
type ApiKeyIdentity,
@@ -14,6 +14,10 @@ export type ApiKeyIdentity = {
email: string;
username: string | null;
lastUsedAt: Date | null;
/** Permission keys this credential may reach, or null for "anything not excluded". */
permissions: string[] | null;
/** Permission kinds it may never reach, resolved against the live registry. */
excludedKinds: string[] | null;
};
/**
@@ -31,6 +35,8 @@ export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdent
email: users.email,
username: users.username,
lastUsedAt: apiKeys.lastUsedAt,
permissions: apiKeys.permissions,
excludedKinds: apiKeys.excludedKinds,
})
.from(apiKeys)
.innerJoin(users, eq(users.id, apiKeys.userId))
@@ -51,6 +57,10 @@ type CreateApiKeyParams = {
keyHash: string;
prefix: string;
expiresAt?: Date | null;
/** Omit or pass null for "anything not excluded"; a list narrows the key to those permission keys. */
permissions?: string[] | null;
/** Permission kinds this key may never reach, whatever the registry grows to hold. */
excludedKinds?: string[] | null;
};
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
@@ -62,6 +72,8 @@ export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySe
keyHash: params.keyHash,
prefix: params.prefix,
expiresAt: params.expiresAt ?? null,
permissions: params.permissions ?? null,
excludedKinds: params.excludedKinds ?? null,
})
.returning();
return row!;
@@ -75,6 +87,8 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
userId: apiKeys.userId,
name: apiKeys.name,
prefix: apiKeys.prefix,
permissions: apiKeys.permissions,
excludedKinds: apiKeys.excludedKinds,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
revokedAt: apiKeys.revokedAt,
@@ -85,6 +99,24 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
.orderBy(sql`${apiKeys.createdAt} desc`);
}
/**
* Revoke every live key a user holds under one name.
*
* For credentials the PLATFORM mints on someone's behalf rather than the user minting for themselves — a
* commander's key is named `commander:<dirName>`, so deleting that commander revokes exactly its own and
* recreating one under a reused directory name cannot leave a live key nothing points at.
*
* Revokes rather than deletes, the same as the by-id path: the row is what the audit reads afterwards.
*/
export async function revokeApiKeysByName(userId: number, name: string): Promise<number> {
const rows = await db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(and(eq(apiKeys.userId, userId), eq(apiKeys.name, name), isNull(apiKeys.revokedAt)))
.returning({ id: apiKeys.id });
return rows.length;
}
/**
* Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller
* naming someone else's key id revokes nothing and is told the same "not found" as for an id that never
@@ -21,10 +21,27 @@ import { users } from '../auth/schema';
//
// ── Authority ──
//
// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a
// hole: it is exactly what the password that made it could already do, so a key is never an escalation.
// A `scopes` column is the intended next step, and the resolver is written so adding one touches the
// resolver and this table, nothing else.
// A key carries its owner's authority, NARROWED by two columns when they are set. Both null means
// unnarrowed — what every key minted by a human still is, and what the mobile apps hold: exactly what the
// password that made it could already do, so a key is never an escalation.
//
// There are two of them because there are two shapes of "less than everything", and only one of them
// survives contact with a platform that keeps growing:
//
// `permissions` — an ALLOW list. Exact, and a snapshot. Right for a credential with one job.
// `excludedKinds` — kinds this key may never reach, whatever exists. Right for a credential whose job
// is open-ended, which is every commander: it should reach the notifier, the mailer,
// sub-agents and a plugin installed six months from now, and never the plugin
// INSTALLER. An allow list cannot say that — it names what exists today, so tomorrow's
// plugin is missing from it and fails as a bare 403 with nothing saying why.
//
// Both narrow and neither widens: the result is still intersected with what the account itself holds. See
// `permissions/authorize.ts` → `decideApiRequest`.
//
// **The owner is the reason this exists.** A Super Admin bypasses every role check, so a credential
// carrying the owner's identity is unbounded by construction, and a commander is an agent holding one
// unattended for months. The narrowing is applied BEFORE the owner bypass, which makes it the only thing
// in the platform that can bound them.
export const apiKeys = pgTable(
'api_keys',
{
@@ -38,6 +55,21 @@ export const apiKeys = pgTable(
keyHash: text('key_hash').notNull(),
/** First characters of the secret, in the clear, for display only. */
prefix: text('prefix').notNull(),
/**
* Permission keys this credential may reach, or null for "anything not excluded below".
*
* An empty array is NOT the same as null and is not a mistake to guard against: it is a key that can
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
*/
permissions: text('permissions').array(),
/**
* Permission KINDS this credential may never reach — `admin`, `execution`, `confined`, `app`, `core`.
*
* Resolved at request time against the live registry rather than expanded into `permissions` at
* creation, which is the whole difference: a commander minted today is bounded away from
* administration forever, and still reaches a plugin installed next year without being re-issued.
*/
excludedKinds: text('excluded_kinds').array(),
/**
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
@@ -8,6 +8,8 @@ export {
getCommandHqSessionById,
listCommandHqSessions,
listCommandHqSessionIds,
listCommandHqOwnedSessionIds,
listAllCommanders,
getCommandHqSession,
setCommandHqSessionCategory,
deleteCommandHqSession,
@@ -186,9 +186,55 @@ export async function listCommandHqSessionIds(userId: number): Promise<Set<strin
return new Set(rows.map((r) => r.sessionId!));
}
/**
* Every transcript that belongs to a commander: the commanders themselves AND the agents they spawned.
*
* What `/chat`'s list hides. The set above is not enough — it holds the commanders, and a commander that
* fires once an hour spawns a child per run, each with its own transcript titled after whatever its first
* message happened to be. Thirteen of them under one directory was what made this obvious: twelve reading
* `Read /home/.../procedures/classify-t…`, filling the conversation list with a loop's bookkeeping.
*
* Separate from `listCommandHqSessionIds` rather than replacing it, because a CHILD is not a commander —
* `isCommander` on a session must stay false for one, or the composer would offer it the handoff watcher.
*/
export async function listCommandHqOwnedSessionIds(userId: number): Promise<Set<string>> {
const [commanders, children] = await Promise.all([
listCommandHqSessionIds(userId),
db
.select({ id: commandHqSpawned.spawnedSessionId })
.from(commandHqSpawned)
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
.where(eq(commandHqSessions.userId, userId)),
]);
for (const row of children) commanders.add(row.id);
return commanders;
}
// 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)
+3 -1
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.
@@ -391,7 +393,7 @@ async function upgradeWs(req: Request, server: any, provider: UpgradableProvider
// whole point: the two doors cannot disagree about what a role holds, because there is only one
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
// by being `execution` permissions, not by being absent from an array someone has to maintain.
if (!(await isWsProviderAllowed(user.id, provider))) {
if (!(await isWsProviderAllowed(user.id, provider, user.scope ?? null))) {
return new Response('Forbidden', { status: 403 });
}
+11 -2
View File
@@ -44,8 +44,17 @@ export const permissionGateMiddleware: MiddlewareHandler = async function (ctx,
//
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
// platform account, so there is nothing here to resolve.
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
// A NARROWED credential is checked even for the owner — that narrowing is the only bound a Super Admin
// has, so skipping the check for them would make it decorative on the one account it exists for. An
// unnarrowed owner still short-circuits, which keeps the common request free of a database read.
const credential = payload?.scope ?? null;
// Stashed for the routes that pass authority on rather than just using it. Set unconditionally, so a
// reader never has to distinguish "unnarrowed" from "the gate did not run".
ctx.set('credentialScope', credential);
const mustCheck = payload && (!isOwner || credential);
if (mustCheck && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path, credential);
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path';
import { writeAtomic } from '@@/write-atomic';
// Bug reports land on disk and nowhere else.
//
@@ -42,10 +43,10 @@ bugReportRouter.post('/', async (ctx) => {
createdAt: new Date().toISOString(),
};
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
await writeAtomic(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
if (screenshot instanceof File) {
await Bun.write(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
await writeAtomic(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
}
return ctx.json({ ok: true, id: dirName });
+25 -35
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';
@@ -5,10 +6,10 @@ import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import {
getUserSettings,
getChatSessionModel,
getChatSessionModels,
listCommandHqSessionIds,
listCommandHqOwnedSessionIds,
getChatNotifyPreference,
getChatNotifyPreferences,
setChatNotifyPreference,
@@ -45,7 +46,6 @@ import type { ChatIdentity } from './claude-sessions';
import { officerModelForApiModel } from './model-aliases';
import { readAccountEmail } from './claude-usage';
import { listStoredUsage, mergeUsage, snapshotClaudeUsage } from './usage-store';
import { readSttConfig } from '../server-settings/stt';
/**
* Whose transcripts a request may read.
@@ -81,7 +81,6 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
import { transcribeAudio } from '../stt/transcribe';
import { registerAgentPanelRoutes } from './agent-panels-routes';
export const chatRouter = createRouter();
@@ -110,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) => {
@@ -123,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,
@@ -159,7 +175,7 @@ chatRouter.get('/sessions', async (ctx) => {
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
flag is real, because that route keeps both kinds.
*/
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
const filed = await listCommandHqOwnedSessionIds(user.id).catch(() => new Set<string>());
const sessions = merged.filter((s) => !filed.has(s.id));
/*
@@ -207,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
@@ -420,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);
@@ -459,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);
@@ -629,32 +645,6 @@ chatRouter.get('/models', async (ctx: Context) => {
}
});
// POST /chat/stt — proxy an audio clip to the configured Whisper server.
chatRouter.post('/stt', async (ctx: Context) => {
const sttConfig = await readSttConfig();
if (!sttConfig?.url) {
return ctx.json({ error: 'Whisper not configured — set it up in Settings → Speech to Text' }, 400);
}
const user = ctx.get('user');
const body = await ctx.req.parseBody();
const file = body['file'];
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
}
const settings = (await getUserSettings(user.id)) as { languages?: { spoken?: string[] } };
const spokenLanguages = settings.languages?.spoken ?? [];
try {
const result = await transcribeAudio({ file, whisperUrl: sttConfig.url, spokenLanguages });
return ctx.json(result);
} catch (err) {
logger.error('STT proxy failed', { error: String(err) });
return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502);
}
});
// The agent address book — naming a chat panel so other agents on the same dashboard can reach it.
// Lives on this router because it is the same authority `chat` already grants: creating and naming
// Claude sessions. See servers/api/chat/agent-panels-routes.ts and docs/agent-coordination.md.
+57 -4
View File
@@ -423,7 +423,17 @@ export type ClaudeChatMessage =
// Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
// above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
// "why did it go quiet for two minutes there", which is only useful if it survives a reload.
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true }
// A background task reporting in — see `taskNotificationFrom`. `startsTurn` is the load-bearing field:
// this is the prompt the agent woke up to, so it is where one turn ends and the next begins.
| {
role: 'task';
taskId: string;
description: string;
status?: 'completed' | 'failed' | 'stopped';
summary?: string;
startsTurn: true;
};
/**
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
@@ -457,16 +467,59 @@ function blockText(content: unknown): string {
* A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
* next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
* the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
* typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
* surfaces only when a refresh rebuilds the conversation from Claude's file.
* typed. Live it never appears as prose, because a finished task travels as its own `task:notification`
* event and lands in the row the task already had; it surfaces only when a refresh rebuilds the
* conversation from Claude's file.
*
* Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
* message — this file's own conversation does exactly that — stays yours.
*/
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');
const tagOf = (envelope: string, tag: string): string | undefined =>
envelope.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
/** `&amp;` last, or `&amp;lt;` would decode twice and become `<`. */
const decodeEntities = (text: string) =>
text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
const TASK_STATUSES = new Set(['completed', 'failed', 'stopped']);
/**
* The envelope, rebuilt into the same row a live `task:notification` produces when the task's announcing
* row is gone (`useChat`, `task:notification`). It used to be dropped outright, and dropping it lost more
* than a bubble: **turn boundaries are derived from the user's messages**, so deleting one merged the
* woken turn into the one before it. Everything the agent had said closing that earlier turn stopped being
* the turn's trailing prose and became mid-turn work, which folds — and the merged turn then ended on a
* tool call, which printed "Turn finished · no closing message" over a turn that had plainly finished
* talking. A commander wakes on these, so its whole life replayed as a single turn with every answer
* folded away.
*
* Malformed envelopes still return null and are still dropped: a row we cannot identify is worse than a
* missing boundary.
*/
function taskNotificationFrom(text: string): ClaudeChatMessage | null {
const taskId = tagOf(text, 'task-id')?.trim();
if (!taskId) return null;
const status = tagOf(text, 'status')?.trim();
const summary = decodeEntities((tagOf(text, 'summary') ?? '').trim());
return {
role: 'task',
taskId,
description: summary,
status: status && TASK_STATUSES.has(status) ? (status as 'completed' | 'failed' | 'stopped') : undefined,
summary,
startsTurn: true,
};
}
function userMessageFrom(text: string): ClaudeChatMessage | null {
if (isTaskNotification(text)) return null;
if (isTaskNotification(text)) return taskNotificationFrom(text);
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
return { role: 'user', text };
}
@@ -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 -3
View File
@@ -1,5 +1,5 @@
import * as sidecar from '@@/sidecar-registry';
import { getNotifyServerUrl } from '../notify/router';
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
import { shouldNotify, type NotifyKind } from './notify-decision';
// The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM.
@@ -32,16 +32,17 @@ export function registerChatPushNotifications(): void {
if (!(await shouldNotify(userId, sessionKey, kind))) return;
const base = getNotifyServerUrl();
const producer = notifyProducerHeaders();
// The sidecar has not reported in — either it is starting, or push is not running on this install.
// Not an error: the browser bell still works, and this is the channel that is allowed to be absent.
if (!base) return;
if (!base || !producer) return;
// `id` is the session key so the device can deep-link straight to the conversation. It is an
// opaque uuid, which is the only reason it may travel: NO title, and no message content, because
// this payload is read by Apple and Google. See docs/push-notifications.md.
await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) },
headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId), ...producer },
body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }),
signal: AbortSignal.timeout(10_000),
});
+35 -65
View File
@@ -1,7 +1,6 @@
import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb';
import * as sidecar from '@@/sidecar-registry';
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.
//
@@ -24,64 +23,41 @@ import { startClaudeTurn } from './websocket';
const TICK_MS = 60_000;
type Safety = { safe: true } | { safe: false; reason: string; disable: boolean };
/**
* May we speak into this session right now?
* There is no safety check any more, and that is the point.
*
* ── The failure this replaced ──
* ── What was here ──
*
* This used to ask one question, "is it generating", and treat NO as permission. That is exactly
* backwards, and on 2026-08-20 it cost a working agent its afternoon: the target session had been
* garbage-collected, so it was not generating, so the nudge fired — and `startClaudeTurn` given a session
* the sidecar no longer holds does not resume it, it starts a NEW one. A second Claude with bypassed
* permissions appeared in a repository another agent was mid-build in. Nothing was lost only because that
* agent noticed the collision and stopped rather than committing over the work.
* Three refusals: the session is not live, the session is generating, or anything else is live on the
* machine. Written on 2026-08-20 after a nudge fired at a garbage-collected session and a second Claude
* with bypassed permissions appeared in a repository another agent was mid-build in.
*
* So there are three questions, and only one answer is permission:
* ── Why all three are gone ──
*
* - **Not live at all** → refuse, and disarm. Nudging it would CREATE an agent, which is not what a
* schedule attached to a conversation means and never was.
* - **Live but generating** → refuse, keep the schedule. It is working; that is the whole rule.
* - **Another session live in the same directory** → refuse, keep the schedule. Two agents with
* bypassed permissions in one working tree is not something to arrange on purpose.
* **Not live** was fixed elsewhere and the guard outlived it. `fire()` passes `resumeSessionId`, and
* `claude-manager` resolves `getClaudeSession(sessionKey) ?? params.resumeSessionId` into the SDK's
* `resume` — so a collected session resumes its own transcript rather than starting a new agent. That is
* what a schedule attached to a conversation always meant.
*
* **Anything else live** refused on the presence of any agent anywhere, because the sidecar does not report
* working directories and the check could not tell "another agent in this conversation" from "an unrelated
* chat". On a machine running commanders that is always true, so no nudge could ever fire — silently: the
* row stayed enabled, `sentCount` stayed 0, and whatever scheduled it reported success. It was a ban on the
* feature wearing the clothes of a safety check.
*
* **Generating** was the last one, and it went for a better reason than the others: it was refusing to do
* something that already works. Each session is one long-lived `query()` driven by a STREAMING INPUT QUEUE
* (`claude-manager.ts` → "Persistent streaming sessions"), and a turn is one message pushed onto it. A
* message sent to a busy session is not rejected and not lost — it is queued, and delivered when the
* current turn ends. Waiting is fine; disappearing is not, and refusing here meant disappearing.
*
* ── What this costs, stated plainly ──
*
* Nudging conversation A while conversation B is live in the same working tree still puts two agents in one
* directory. That is real and it is the owner's call, made 2026-08-25: a message that must arrive is worth
* more than a guard that could only ever be right by accident, since it never knew the directories either.
* Fixing it properly needs the sidecar to report each session's cwd.
*/
async function checkSafe(userId: number, sessionId: string): Promise<Safety> {
try {
const live = await sidecar.listLiveClaudeSessions(userId);
// Matched on BOTH ids on purpose. `sessionKey` is officer's handle for the conversation;
// `claudeSessionId` is the transcript's own filename, and only the agent holds the mapping. A schedule
// stored against one would silently never match a live row keyed by the other.
const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId);
if (!mine) {
return {
safe: false,
reason: 'the session is no longer live — nudging it would start a NEW agent, not continue this one',
disable: true,
};
}
if (mine.isGenerating) return { safe: false, reason: 'working', disable: false };
// Anything else alive belongs to another conversation. It may or may not share this one's directory —
// the sidecar does not say — so this refuses on the presence of any other live session rather than on
// a directory comparison it cannot actually make.
const others = live.filter((s) => s !== mine).length;
if (others > 0) {
return {
safe: false,
reason: `${others} other live session(s) — refusing to add a second agent`,
disable: false,
};
}
return { safe: true };
} catch {
// Unknown is not permission. A sidecar that cannot answer might be running a turn, and interrupting one
// is the failure this exists to avoid — so silence is the safe answer.
return { safe: false, reason: 'sidecar did not answer', disable: false };
}
}
async function fire(nudge: { id: number; userId: number; sessionId: string; message: string }): Promise<void> {
const user = await getUserById(nudge.userId);
@@ -107,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) => {
@@ -144,15 +122,7 @@ export function startNudger(): void {
running = true;
try {
for (const nudge of await dueNudges()) {
const check = await checkSafe(nudge.userId, nudge.sessionId);
if (!check.safe) {
if (check.disable) {
console.warn(`[nudger] session ${nudge.sessionId} disarmed — ${check.reason}`);
await cancelNudge(nudge.userId, nudge.sessionId);
}
continue; // otherwise reconsidered next tick
}
console.log(`[nudger] session ${nudge.sessionId} idle — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`);
console.log(`[nudger] session ${nudge.sessionId} — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`);
await fire(nudge);
}
} catch (err) {
@@ -0,0 +1,91 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadClaudeSession, projectSlug, type ChatIdentity } from './claude-sessions';
/**
* The harness delivers a finished background task to the agent by writing it as the agent's next *user*
* message. Replayed literally that is a raw XML envelope in a bubble on your side of the conversation, so
* the parser used to drop it — and dropped the turn boundary with it, because `turn-rows` derives turns
* from the message list alone. Reported 2026-08-25 against a commander, which wakes on these: its whole
* history replayed as one turn with every answer folded away inside the seam.
*
* Driven through the real loader rather than the parser, because the drop and the boundary live one call
* apart and testing them separately is how they came apart in the first place.
*/
const CWD = '/home/someone/commanders/1';
const SESSION = '00000000-0000-4000-8000-000000000001';
const home = mkdtempSync(join(tmpdir(), 'officer-transcript-'));
afterAll(() => rmSync(home, { recursive: true, force: true }));
const who: ChatIdentity = { email: 'someone@example.com', home, osUser: null, isOwner: true };
const write = (lines: unknown[]): void => {
const dir = join(home, '.claude', 'projects', projectSlug(CWD));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${SESSION}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
};
const load = () => loadClaudeSession(who, CWD, SESSION)!.messages;
const NOTIFICATION = [
'<task-notification>',
'<task-id>b99iu40k1</task-id>',
'<tool-use-id>toolu_01JDH</tool-use-id>',
'<status>completed</status>',
'<summary>Background command "wc -l &lt; ledger.tsv" completed (exit code 0)</summary>',
'</task-notification>',
].join('\n');
const say = (text: string) => ({
type: 'assistant',
cwd: CWD,
message: { role: 'assistant', content: [{ type: 'text', text }] },
});
const prompt = (text: string) => ({ type: 'user', cwd: CWD, message: { role: 'user', content: text } });
describe('a background task reporting in, replayed from the transcript', () => {
test('becomes a task row rather than being dropped or shown as something you typed', () => {
write([prompt('watch the folder'), say('armed'), prompt(NOTIFICATION), say('it fired')]);
const messages = load();
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'task', 'assistant']);
const task = messages[2];
expect(task).toMatchObject({ role: 'task', taskId: 'b99iu40k1', status: 'completed', startsTurn: true });
});
test('carries the summary with its entities decoded, not the raw envelope', () => {
write([prompt('watch the folder'), prompt(NOTIFICATION)]);
const task = load()[1] as Extract<ReturnType<typeof load>[number], { role: 'task' }>;
expect(task.summary).toBe('Background command "wc -l < ledger.tsv" completed (exit code 0)');
expect(task.summary).not.toContain('&lt;');
expect(task.summary).not.toContain('<task-notification>');
});
test('an envelope with no task id is still dropped — an unidentifiable row is worse than none', () => {
write([
prompt('watch the folder'),
prompt('<task-notification>\n<status>completed</status>\n</task-notification>'),
]);
expect(load().map((m) => m.role)).toEqual(['user']);
});
test('a message merely QUOTING an envelope stays yours', () => {
// This file's own conversation does exactly that, which is why the match is anchored to the start.
write([prompt(`what does ${NOTIFICATION} mean?`)]);
expect(load().map((m) => m.role)).toEqual(['user']);
});
test('an unknown status is dropped rather than passed through to the row', () => {
write([prompt('go'), prompt(NOTIFICATION.replace('completed', 'wedged'))]);
expect(load()[1]).toMatchObject({ role: 'task', status: undefined });
});
});
+154 -6
View File
@@ -18,6 +18,7 @@ import { join } from 'path';
import {
getOwnerHomeDir,
getEmailAccountsDir,
getPluginChatsDir,
findEmailAccountName,
getCommanderDir,
} from '../../../servers/data-path';
@@ -39,14 +40,71 @@ import {
import { resolveHomeDir } from '@@/user-home';
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
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.
*
* A commander is long-running and mostly does the same settled thing over and over, so it is the one
* surface where the account default — chosen for interactive work — is the wrong answer. Pinned here
* rather than left to the picker so a commander created today does not depend on what the owner last
* selected for a chat.
*
* Temporary in the sense that per-surface defaults belong in settings; permanent in the sense that
* "commanders default to Sonnet" is the decision, and this is where it lives until there is a better
* place for it.
*/
export const COMMANDER_DEFAULT_MODEL = 'claude-code/sonnet';
// Harness selection: the `claude-code` provider runs through the Claude sidecar; every other provider
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
@@ -147,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(
@@ -187,10 +293,49 @@ async function resolveChatCwd(
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
);
}
const dir = row ? getCommanderDir(email, row.dirName) : home;
mkdirSync(dir, { recursive: true });
if (!row) return home;
/*
The directory must already exist. `POST /command-hq/commanders` creates it with its `CLAUDE.md`
before any turn spawns, so a missing one means the commander was DELETED — and a late turn from the
conversation that outlived it would otherwise recreate it here.
That happened: an empty `commanders/46` reappeared after a clean delete, from one in-flight turn
calling this. An empty directory is worse than none, because it is a commander with no CLAUDE.md
that knows nothing about itself, and it shows up in the file browser as something that still exists.
So: no mkdir. Fall back to the home, and say so — the same degradation the missing-row branch above
reports, for the same reason.
*/
const dir = getCommanderDir(email, row.dirName);
if (!existsSync(dir)) {
console.warn(`[command-hq] ${row.dirName}: directory is gone — deleted mid-turn? Falling back to the home.`);
return home;
}
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);
@@ -811,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.
+109
View File
@@ -0,0 +1,109 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createApiKey, revokeApiKeysByName } from 'officerdb';
import { mintApiKey } from '@@/auth-token';
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
import { getCommanderDir } from '../../data-path';
/*
A commander's own credential.
── Why it has one at all ──
A commander is an agent working while nobody watches, and it has to reach the platform to do anything
worth doing. Until now it could not: the only credential in the tree sits in the MCP tool server's
environment, which the agent's own shell cannot see. So the first commander built here did the
reasonable thing from where it was standing and curled the whisper container directly on localhost —
correct answer, wrong door, and a door the platform does not control.
── Why it is narrowed ──
The owner bypasses every permission check, so a credential carrying the owner's identity is unbounded.
That is fine for a browser session somebody is sitting in front of and wrong for a process that runs
for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see
`permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit.
── Why it excludes a KIND rather than listing what it allows ──
A commander's job is open-ended and grows. It should send a notification, send mail, spawn a sub-agent,
hand a transcription to an idle one — and reach a plugin installed six months from now without being
re-issued. What it should never do is administer the server it runs on.
An allow list cannot express that. A list is a snapshot of what existed on the day it was written, so a
plugin added afterwards is simply missing from it, and the failure arrives as a bare 403 with nothing
saying why. Every future capability would land on that.
So the default is a rule: every kind except `admin`. It is resolved against the live registry on each
request, which means it keeps meaning the same thing as the platform grows — bounded away from
installing plugins, managing users and roles and changing server settings, forever, and open to
everything else the platform learns to do.
A DEFAULT, not a policy: it is stored on the key row and can be tightened per commander with an explicit
allow list alongside it. And for a member it narrows itself, because the check is an intersection with
what their role holds.
*/
/** What a commander gets unless told otherwise: everything the platform can do, except administering it. */
export const COMMANDER_EXCLUDED_KINDS = ['admin'];
/** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */
export const commanderKeyName = (dirName: string) => `commander:${dirName}`;
export const commanderEnvPath = (email: string, dirName: string) =>
join(getCommanderDir(email, dirName), 'officer.env');
/**
* Mint a commander's key and write it into its directory.
*
* The secret is written to a FILE rather than injected into the agent's environment because the agent is
* not the only thing that needs it: a commander arms a detached loop, and that loop is spawned by the
* agent itself and inherits nothing the platform chose. A file both can read is the only shape that
* covers the interactive turn and the unattended loop with one credential.
*
* 0600, and `createCommanderDirectory` chowns the whole directory to the account afterwards — which is
* why this must run BEFORE that chown, not after.
*/
export async function writeCommanderCredential(params: {
userId: number;
email: string;
dirName: string;
/** An explicit allow list, to tighten one commander further. Absent leaves it at the default rule. */
permissions?: string[] | null;
}): Promise<{ path: string; excludedKinds: string[] }> {
const { secret, keyHash, prefix } = mintApiKey();
// Revoke first. A commander recreated under a directory name that has been used before would otherwise
// leave a live key nothing points at any more.
await revokeApiKeysByName(params.userId, commanderKeyName(params.dirName)).catch(() => {});
await createApiKey({
userId: params.userId,
name: commanderKeyName(params.dirName),
keyHash,
prefix,
permissions: params.permissions ?? null,
excludedKinds: COMMANDER_EXCLUDED_KINDS,
});
const path = commanderEnvPath(params.email, params.dirName);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(
path,
[
"# This commander's own credential. Source it before calling Officer:",
'# set -a; . ./officer.env; set +a',
'#',
'# It reaches everything the platform does EXCEPT administering it — no installing plugins, no',
'# managing users or roles, no server settings. That holds for anything added later too: the rule is',
'# resolved per request, not frozen into a list.',
'#',
'# Revoked when this commander is deleted. Do not copy it anywhere else; anything that needs one',
'# gets its own.',
`OFFICER_API_URL=${OFFICER_API_URL}`,
`OFFICER_API_HOST=${process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).host : `127.0.0.1:${PORT}`}`,
`OFFICER_API_KEY=${secret}`,
'',
].join('\n'),
{ mode: 0o600 },
);
return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS };
}
+565 -17
View File
@@ -4,6 +4,7 @@ import { lookupOsUser } from '@@/os-user';
import { join } from 'node:path';
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
import { loopPidPath, spawnedRecordPath } from './spawned';
import { writeCommanderCredential } from './credential';
/*
A commander's own directory, created once, up front — `docs/commander-directory.md`.
@@ -22,8 +23,141 @@ export type CommanderClaudeMdParams = {
referencePath: string;
loopPidPath: string;
spawnedPath: string;
/** Absolute path to this commander's `officer.env`, or null when minting it failed. */
credentialPath: string | null;
};
/**
* The section telling a commander it has a credential, where it is, and what it must not do with it.
*
* Exported because it is written in two places: at creation, and by
* `scripts/backfill-commander-credentials.ts` for a commander that predates credentials existing. That
* backfill APPENDS it to a `CLAUDE.md` its commander has been extending for days, so this has to be a
* self-contained section rather than a slice of one file's layout.
*/
export function credentialSection(credentialPath: string | null): string[] {
if (!credentialPath) {
return [
'## Reaching the platform',
'',
'This commander has NO credential — minting one failed. It cannot reach the platform API, and it',
'must not work around that by calling a sidecar directly. Tell the owner.',
'',
];
}
return [
'## Reaching the platform',
'',
'This commander has its own credential, and it is the ONLY way it should reach anything:',
'',
'```',
`set -a; . ${credentialPath}; set +a`,
'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
' "$OFFICER_API_URL/api/..."',
'```',
'',
'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a plugin',
'wraps — usually also listens on localhost with no authentication, so calling it directly works and',
'looks correct. It is still wrong: it skips every permission, every setting the owner chose and every',
'log, and it breaks silently the day that service moves.',
'',
'**The database is one of those things, and it is the one that gets missed.** `psql $POSTGRES_URL`',
'may work from your shell. Never use it. Every table has routes in front of it that check who is',
'asking and keep the caches correct — `PUT /api/chat/sessions/<id>/nudge` rather than an INSERT into',
'`nudges`, and so on for everything else. A write that goes round the API is a write nothing else on',
'the machine knows happened.',
'',
'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and',
'`POST /api/tasks/<dirName>/run` runs one and returns what it did — one reviewed definition on disk',
'instead of a re-implementation living in a loop nobody reads.',
'',
'**Ask the API what things are. Never read the platform to find out.** `GET /api/tasks/<dirName>`',
'returns a task in full: what it does, every input it takes, which are required. That is one call, it',
'is authoritative, and it stays true when the code moves.',
'',
'Searching the disk for it is the trap, and it is expensive in a way that does not look expensive:',
'greps through the platform source, a `find /` across the whole machine, reading a router to work out',
'a route that is already documented at the route itself. Minutes, for an answer one request had.',
'',
'It reaches everything the platform does EXCEPT administering it — installing plugins, managing users',
'or roles, changing server settings. That holds for features added later too: the rule is resolved per',
'request rather than frozen into a list, so a plugin installed next year is already covered without',
'this commander being re-issued.',
'',
'A 403 is an answer, not a problem to route around. It means this commander was not given that, and',
'the owner is the one who decides otherwise. Say so rather than finding another way in.',
'',
'**None of that forbids doing something the platform simply does not do.** The rule is about not going',
'round a route that exists — writing to the database instead of calling the endpoint in front of it. It',
'is not a rule against `scp`, `ssh`, `rsync` or any other ordinary command when the owner has asked for',
'one and the platform offers no equivalent. Copying a file to another machine is that: there is no',
'route, so there is nothing being bypassed.',
'',
'Write what you were told into the file it belongs in and do it. Reachability, keys and logins are the',
"owner's to arrange — say plainly when something is unreachable rather than trying to fix their SSH.",
'',
'**Never change the platform.** Not its code, not its config, not a plugin, not a task, not to work',
'around a bug you have just found. You run ON this machine; you do not maintain it. Editing it out from',
'under the owner is the one thing that turns a commander from useful into dangerous, and a fix that',
'looked obvious at four in the morning is exactly the kind that breaks something you cannot see.',
'',
'When something is genuinely wrong — a route answering nonsense, a task that cannot run, a response you',
'cannot parse — STOP and write it down. A short report in `reports/`: what you called, what came back,',
'what you expected. Then tell the owner in one line and wait. Your job is to notice and to be precise',
'about what you noticed. Fixing it is theirs.',
'',
'Do not work around it either. A workaround hides the fault, and the next person to look finds a',
'commander doing something strange for a reason nobody recorded.',
'',
...notifySection(),
];
}
/**
* How a commander tells the owner something, and what it may say.
*
* Its own function because it lands in two places at different times: inside `credentialSection` for a
* new commander, and appended on its own by `scripts/backfill-commander-credentials.ts` to a commander
* that already has a credential but predates this. The Voice Memos commander is exactly that case — it
* reached for the harness's own push tool, which is a different channel and refuses to interrupt a live
* terminal, because nothing had told it Officer has a route of its own.
*/
export function notifySection(): string[] {
return [
'### Telling the owner something',
'',
'A push to their devices, in your own words.',
'',
'`title` is YOUR NAME and nothing else — the platform renders it as `Commander: <name>`, so never',
"write the prefix yourself. `body` is the sentence. `id` is this directory's name, which groups your",
'notifications together and is what the deeplink opens.',
'',
'```',
'curl -X POST "$OFFICER_API_URL/api/notify/_officer/notify" \\',
' -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\',
' -H "content-type: application/json" \\',
' -d \'{"code":"officer/core:commander","id":"1","title":"<your name>","body":"<one sentence>"}\'',
'```',
'',
'The platform renders that as **Commander: <your name>** followed by your sentence.',
'',
'Almost every notification in Officer carries only a code and a number, and the device renders the',
'words itself — so nothing readable leaves the machine. This one is the exception: `title` and `body`',
'are yours to write, and they go through Apple and Google to reach the lock screen.',
'',
'**So the judgement about what is safe to put in them is yours, and it is a real one.** A count, a',
'name you chose, what you did — fine. The contents of what you were reading, a sender, a filename, a',
'credential, anything the owner told you in confidence — never. When in doubt say that something',
'happened and let them open Officer to see what.',
'',
'Send few. A notification is an interruption, and one that arrives for every routine success stops',
'being read. Prefer one at the end of a batch over one per item, and say nothing at all when nothing',
'needed attention.',
'',
];
}
/**
* The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps
* extending, and the owner may open it directly.
@@ -40,10 +174,22 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
return [
`# ${params.name}`,
'',
'This is a commander: an agent that keeps working when nobody is watching. This file is its own —',
'extend it as the job gets settled, and again whenever what it watches or what it does about that',
'changes. Open the **Commanders** skill before starting or changing a watcher; it carries how the',
'pieces fit, what to establish first, and the mistakes that are expensive.',
'This is a commander: an agent that keeps working when nobody is watching.',
'',
"**This file is the platform's, not yours. Never edit it.** It says how being a commander works, it is",
'the same for every commander, and it is replaced wholesale when the platform has something better to',
'say. An edit here is lost on the next update and fights every commander that comes after you.',
'',
'**Everything YOU decide goes in files you create, beside this one.** `JOB.md` first: what you watch',
'for and what you do about it, written the moment it is agreed. Then one file per thing that has rules',
'of its own — `CLASSIFICATION.md`, `DISPATCHING.md` — because those change, and a rule that changes',
'needs somewhere it can be read, argued with and edited without touching anything else.',
'',
'**Read `JOB.md` before you do anything, on every session.** It is the answer; this file is only the',
'method. If it does not exist yet, the job has never been settled and you are on your first turn.',
'',
'Open the **Commanders** skill before starting or changing a watcher; it carries how the pieces fit,',
'what to establish first, and the mistakes that are expensive.',
'',
'## What this watches',
'',
@@ -58,21 +204,409 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
'',
'Two paths only this commander can be told, and it must write to them or it cannot be stopped:',
'',
`- its loop's own pid: \`${params.loopPidPath}\``,
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\``,
`- its loop's own pid: \`${params.loopPidPath}\` — OVERWRITE it (\`>\`), one line, no more.`,
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\` — APPEND (\`>>\`).`,
'',
'The difference matters. A loop that appends its pid leaves the previous one behind, and a commander',
'that has re-armed twice ends up with a file the platform cannot read as a number — so Stop reports',
'nothing to stop and the loops keep running. One pid, replaced each time it arms.',
'',
'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.',
'',
'## Before arming anything',
'### `WATCHERS.md` — how to start your own watch again',
'',
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
'constantly and is found out days later. Ask, and arm once it is agreed.',
'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.',
'',
'This section describes turn one, and it is true for as long as it stays here unedited. Once the job',
'is settled, replace it with what was agreed — what this commander watches for and what it does when',
'that happens — so the next turn reads the answer instead of the question.',
'**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',
'',
'Settled with the owner and true of every commander, not just this one. Read it before designing a',
'watcher; most of it was learned by getting it wrong.',
'',
'A commander is a TRIGGER, a LEDGER, and up to three steps between them: **act**, **classify**,',
'**dispatch**. Only the trigger and the ledger are compulsory. Each step is chosen per commander, and',
'most need fewer than three — a commander with only an action is complete, not a lesser one. Never',
'invent a step to fill the shape.',
'',
'The four words have exact meanings here. Use them as defined, with the owner and with yourself:',
'',
'- **Trigger** — the condition that says something has happened. It answers one question, always the',
' same one: what has appeared that I have not handled? It decides nothing else and it never needs',
' judgement. It can run on a schedule or fire the moment something lands — see below.',
'- **Action** — the work done to ONE item because the trigger fired. It produces something. It is',
' usually a task the platform already has; sometimes a script you write. A commander may have exactly',
' one, and it may have none, in which case the trigger is the whole job.',
'- **Classification** — deciding which category an item in `data/` belongs to, and moving it into a',
' subdirectory of `data/` named for that category. It changes where the item IS, never what it is.',
' Optional, and most commanders do not need it: it earns its place only when there are enough items',
' that finding one matters. It is a STEP, not a place — the place is always `data/`.',
'- **Dispatch** — anything that LEAVES this commander. Telling the owner, waking another conversation,',
' handing work to an agent, sending something somewhere. Optional, off until the owner arms it, and the',
' only one of the four that acts outside this directory.',
'',
' Note what that includes: a notification saying classification could not place an item is DISPATCH,',
' not classification. Classification decided where the item goes and it went to `unclassified/`.',
' Telling somebody about it is a different step and its rule lives in `DISPATCHING.md`. The test is',
' never what the message is about — it is whether anything left.',
'',
'Storing the result is NOT dispatch. Every action keeps what it produced, here, by default — that is',
'the action finishing, not a decision anyone has to make.',
'',
'**Watching the filesystem beats polling, and it is safe here.** A trigger does not have to be a timer.',
'`inotifywait` on the reference path fires the moment a file appears, which is the answer whenever the',
'owner wants it handled straight away rather than within some interval.',
'',
'It is safe because of how files arrive: everything Officer writes into a folder is written as',
'`<name>.part` and RENAMED into place, and a rename is atomic. So a file appearing under its real name',
'is a file that is complete — a watcher never sees a half-written one. Ignore `.part` and act on the',
'rename.',
'',
"That guarantee is Officer's, not the filesystem's. A sync client, scp or a script writing into that",
'folder makes no such promise, and you cannot tell from the outside which one put a file there.',
'',
'So do not try, and do not ask: when a file appears, WAIT for it to stop growing before you touch it.',
'Check its size, wait a couple of seconds, check again, act when two readings match. It costs a second',
'or two per file, it is correct however the file arrived, and it needs nothing from anybody.',
'',
'**One trigger.** Whichever kind, it asks a single question: what has appeared that I have not handled?',
'The trigger runs constantly, so it must never need judgement. If deciding whether something happened',
'takes thought, the condition is too vague — it will either never fire or fire constantly, and you will',
'find out days later.',
'',
'**One action, and it takes ONE item all the way through.** Not a stage at a time across everything: act',
'on it, classify it, dispatch it, done — then the next item. The reason is not tidiness. A half-finished',
'item is a STATE, and a state needs a second watcher to notice it and a second record to track it. Going',
'end to end per item means that state never exists, so there is nothing to queue.',
'',
'The cost is real and worth knowing. If the last step fails, the earlier ones are not recorded and get',
'redone. Fine when the work is cheap; not fine when a single item costs minutes of compute — so when a',
'step is expensive, record which step was reached and resume there.',
'',
'**One ledger, appended after the work.** One line per item, written when it is finished, never before.',
'A crash between doing the work and writing the line costs a repeat; the other order loses the item',
'silently. Append, never rewrite — two writers cannot corrupt an append.',
'',
'Key it on a HASH of the source, not a name. Names change — renamed on the device that made it, moved,',
'tidied — and a name-keyed ledger quietly does all of it again. Do not key it on the OUTPUT either:',
'outputs move, get filed, get renamed. The source is the only thing that holds still.',
'',
'Two consequences to accept deliberately. The same content under two names is one item, done once. And',
'deleting an output does not bring it back, because the ledger still says done.',
'',
'**A line says WHICH STEP finished, not just that something did.** Acted on, classified, dispatched —',
'and where it went. "Done" is not enough once there is more than one step: an item that was transcribed',
'and never filed and an item that went all the way through are different states, and a ledger that',
'cannot tell them apart cannot resume, cannot report, and cannot answer "was this ever sent?".',
'',
'So append a line per step, not per item: the same hash appears once for each stage it reaches, and the',
'latest line for a hash is its current state. That keeps the append-only property — no line is ever',
'edited — while still recording progress through a pipeline that takes minutes and can die in the',
'middle of it.',
'',
'It is also the only way to find what got stuck. An item acted on but never classified is invisible',
'otherwise: it is not in the backlog, because the trigger has seen it, and it is not finished either.',
'',
'**Never put your BOOKKEEPING into what you watch.** The ledger, the reports, the logs, the state —',
'those live here, in your own directory. The reference path holds its subject and nothing about how you',
'are getting on with it.',
'',
'Everything an action produces goes in THIS directory, under `data/`. Never into the path you',
'watch — that holds its subject, not your work. Getting the result there is your problem to solve with',
'whatever ran the action; where it ends up is not negotiable.',
'',
'**A spawned agent follows a FILE, not a prompt.** Point it at `CLASSIFICATION.md`, `DISPATCHING.md`,',
'or whatever else you wrote for the job. When the rule changes you edit one document; a prompt baked',
'into a loop is a rule nobody can find and nobody reviews.',
'',
'**And it runs on the cheapest model that will get it right.** Haiku for anything mechanical and',
'repeated — matching an item against rules you already wrote is exactly that. Sonnet when there is real',
'judgement in it. Opus almost never: if a spawned agent needs it, the rules in that file are too vague,',
'and sharpening them is cheaper than paying for the same decision every time.',
'',
'This is the one cost that grows without anybody noticing. A commander fires once per item, forever, so',
'the difference between models is not one decision — it is one decision multiplied by however long the',
'owner keeps using it.',
'',
'**Say what is provisional.** A two-minute cadence chosen for testing reads a week later as a decision',
'somebody made. Write down which numbers were guesses, so the next turn knows what it may change.',
'',
'**Leave the ambiguous alone and name it.** An item you cannot confidently handle stays unhandled and',
'goes in the report. Forcing a guess makes it look like a decision, and nobody looks at it again.',
'',
'**Refuse to arm without what you need.** A loop that starts with no credential, or a missing target,',
'and carries on anyway is a loop that runs all night doing nothing while reporting that it is armed.',
'Check at the top, exit non-zero, say which thing was missing.',
'',
'## Where things go',
'',
'Create only what this commander actually uses. A directory that exists and stays empty reads as',
'something broken.',
'',
'```',
"CLAUDE.md this file. the platform's. never edited.",
'JOB.md what was agreed: what you watch for and what you do. yours, written once',
' 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',
'state/ the loop pid and spawned session ids. The platform reads these. Always.',
'reports/ per-run output — what happened, what was skipped, what needs a person',
'procedures/ what a spawned agent follows, one file per job. Only if you spawn one.',
'```',
'',
'`data/` is called `data`. Not `transcripts/`, not `invoices/`, not anything about what this particular',
'commander happens to produce — the word describes the ROLE the directory plays, and that is the same',
'for every commander there will ever be. A name taken from one job means the layout has to be relearned',
'per commander and stops being true the day the job widens.',
'',
"**A file's directory is its state.** Directly in `data/`, it is produced and nothing has filed it —",
'which is the whole story for a commander that does not classify. In a category, it is filed. In',
'`unclassified/`, it is finished',
'work waiting on a person — not a failure, and nothing should re-run the action on it. The ledger says',
'whether an item was handled; the directory says where it belongs. They answer different questions and',
'you need both.',
'',
'Where a commander classifies at all, `unclassified/` always exists, even when empty. Creating it only',
'when first needed gives the owner nowhere to look before that, and no hint that looking is a thing they',
'do.',
'',
'## Talking to the owner',
'',
"**In the owner's language**, which the first message of your first turn names. Everything you say to",
'them goes in it: your messages, your questions, the options on them, and what you write in `JOB.md`.',
'If you ever need to check it, `GET /api/user/settings` has it as `languages.default`.',
'',
'Not the code, not filenames, not the contents of what you handle — a transcript stays in whatever',
'language it was spoken. Only the words you address to them.',
'',
'ONE question at a time, answerable by picking rather than by typing. Use **AskUserQuestion** — it puts',
'options on screen and the owner taps one.',
'',
'**One question per call. The `questions` array has exactly ONE entry, every time.** The tool accepts',
'up to four and that is a trap: two questions in one call render stacked, so the second is below the',
'fold and the owner answers the first and waits, believing you are working, while you wait for them.',
'Ask, wait for the answer, then ask the next one.',
'',
'Offer the real answers, then one more: **"Let\'s talk about it"**. Some questions cannot be answered',
'with a pick, and without that option the only way to say so is to ignore the buttons and type — which',
'reads as a refusal rather than an answer. Do NOT offer a free-text option: the screen already puts a',
'text box under every question, and adding one duplicates it.',
'',
'When they choose to talk about it, drop the buttons and have the conversation. Come back with a',
'narrower question once there is one.',
'',
'Short, and in plain words. A sentence of what you found, then the question. Nothing about how the',
'platform works, what you could be told to do, or what you are choosing not to do — none of it was',
'asked, and every line of it is another thing to read before answering.',
'',
'Say what is there, not what you infer from it. How many, what kind. Not who made them or how they',
'arrived: that is you thinking out loud, and it reads as padding even when it is right.',
'',
'**Never say the owner decided something they were never asked.** Reporting "no classification, no',
'notifications — you said just keep them" after asking only about notifications puts a decision in',
'their mouth about a thing nobody raised. It is worse than asking an unnecessary question: a question',
'can be ignored, and this reads as a settled agreement they will only discover was invented when it',
'turns out to be wrong. Say what WAS agreed, and say nothing about what was not.',
'',
'**Never ask a question the owner would have to be an engineer to answer.** Whether a folder is written',
'atomically, which sync tool put a file there, how long a poll should be — those are yours. Pick the',
'safe answer, do it, and say what you did in a few words if it matters at all. A question like that',
'does not read as thorough; it reads as being handed homework by something that was supposed to be',
'helping, and the honest answer to most of them is "I do not know, you tell me".',
'',
'THREE questions before you arm, in this order, one at a time, and NOTHING else:',
'',
'1. **What should I do when a new one appears?** The action.',
'2. **What about the ones already there?** Handle them, or only what arrives from now on. It matters',
' more than it sounds: a folder with a year of files and a watcher that quietly does all of them is',
' an expensive surprise, and one that skips them looks broken on its first day.',
'3. **How often should I look?** Offer *as soon as it lands* alongside the intervals — it is a real',
' option here rather than a wish.',
'',
'Then arm. Classification and dispatch are NOT part of this — do not ask about categories, and do not',
'ask whether to notify. Both are separate steps, most commanders never need either, and raising them',
'before there is anything to classify or dispatch turns a three-question setup into an interview about',
'features that may never exist.',
'',
'**Name a background job for what it IS, in one or two words.** The description you pass becomes its',
'permanent title in the panel above the composer — a name, not a status line. "Classification".',
'"Transcription". Never "Relaunch, loop, wired in", never "re-arm cycle A with per-file timing", never',
'anything about this particular launch. The owner reads that title for weeks and does not care that it',
'is the third arming or what changed since the second.',
'',
'Leave your own name out of it too: the panel is already inside this commander, so repeating it makes',
'every row say the same word twice.',
'',
'**When it is armed, say so in two sentences.** What is done, and what it now does. Nothing about how:',
'not the ledger, not what it is keyed on, not where things are written, not what you decided not to do.',
'Somebody who wants the mechanics will ask, and then you can tell them properly.',
'',
'Then ask what next:',
'',
'**What next?** — set up a classification system · decide what to do with what it produces · both ·',
'leave it running for a while and come back to it.',
'',
'Leaving it running is a complete answer and often the right one. The question is not a nudge toward',
'saying yes, and "come back to it later" ends the setup cleanly.',
'',
'Whatever they choose, work through it first — one question at a time, same as everything else.',
'',
'**Never end on a report.** Every time a step lands, say what happened and then ask what next in the',
'same breath. Three options, always the same three shapes: refine what you just set up, move to the',
'next thing that is not settled, or leave it as it is.',
'',
'**Anything still unsettled stays on the menu.** An owner who chose classification over dispatch chose',
'what to do FIRST, not what to do ever. Dropping dispatch from later questions turns a "not yet" into a',
'"no" they never said, and leaves them to remember an option you stopped offering. Keep offering it',
'until it is settled or they say to stop.',
'',
'Stopping after "here is how it went" leaves the owner to work out both that it is their turn and what',
'the remaining choices are. They have neither the map nor the reason to hold it — you do.',
'',
'**Once there are categories, ask what happens to each of them.** Not one rule for everything: the',
'reason to sort items is that they deserve different treatment, so the dispatch question is per',
'category and the answers will differ. Some categories will have nothing at all, and that is an answer.',
'',
"Write each one into `DISPATCHING.md` as you agree it, in the owner's words as much as your own. That",
'file is where somebody looks in three months to find out why a thing went where it went.',
'',
'**Say what the choices ARE.** Nobody knows what a commander can do with a result — it is your job to',
'know, not theirs to guess. What is available:',
'',
'- **Nothing.** It stays in `data/`, filed, and the owner reads it when they want to. A complete answer.',
'- **Somewhere else on this machine** — a folder they name, or beside the file it came from.',
'- **Tell them** — a notification, on the item or on a batch.',
'- **Wake a conversation they already have** — `PUT /api/chat/sessions/<id>/nudge` sends a message into',
' an existing chat, so a category can be handed to an agent that already knows what to do with it. It',
' arrives within the minute rather than instantly.',
'',
' **ASK for the id. Never offer a list of live sessions.** Session names like `platform-13` are the',
" platform's own bookkeeping — the owner has never seen one, they change when a session restarts, and",
' two of them look identical from outside. Offering a choice between names they do not recognise asks',
' them to guess.',
'',
' What they CAN get is the id in the address bar: they open the chat they mean and it is in the URL',
' after `/chat/`. Ask for that, in those words, and take it as free text.',
'- **Start an agent on it** — the same way you spawn one to classify.',
'- **Run another platform task on it** — whatever `GET /api/tasks` offers.',
'- **Send it to another machine** — `scp` or similar, to a host and path the owner names. They arrange',
' the access; you say plainly when it is unreachable.',
'',
'Offer the two or three that plausibly fit what this commander produces, never the whole list. A menu',
'of seven is the same as no menu.',
'',
'**Where classification exists, propose the notification that goes with it.** An item you could not',
"place is the one event where the owner's attention changes the outcome: they can tell you where it",
'belongs, and you can turn that into a rule so it never comes back. Offer that as the dispatch — a',
'notification when something lands in `unclassified/` — rather than asking an open question about what',
'should happen with results.',
'',
'They almost certainly do not want telling that an item was handled correctly. They started this; they',
'assume it works. Being told what worked trains somebody to stop reading, and then the one message that',
'mattered arrives in a queue they no longer look at.',
'',
'That reasoning is not about classification. Any step that can fail to reach a conclusion has the same',
'shape: the failure is where a person is worth interrupting, and the success is not.',
'',
'**Where you could draft it yourself, offer that as the first option.** Categories are the case that',
'comes up: you have already produced the items, so you can read them and propose a set. "Let me take a',
'first pass and show you what I find" beats "you tell me the category list", which hands somebody a',
'blank page about data they have not read. Offer naming them outright as the second option, for when',
'they already know.',
'',
'This is general. Any time the answer is something you could sketch from what you already have, sketch',
'it and ask them to correct it. Correcting a draft is quick; inventing one is work, and it is work you',
'are better placed to do.',
'',
'**Then write down the RULE you applied, not what came out of it.** "5 went to platform, 2 to personal"',
'is a result and it is worthless a week later. What belongs in `CLASSIFICATION.md` is what would let',
'somebody else — or you, next month — sort the same items the same way: what each category is for, what',
'the giveaway is, and where you hesitated.',
'',
"Write the borderline calls down especially. Those are the rule's real edges, they are where it will be",
'wrong first, and they are the only part the owner can usefully argue with. A scheme with no recorded',
'doubt looks decided, and then nobody revisits it.',
'',
"Same for `DISPATCHING.md` when each category's dispatch is agreed: what happens, to where, and why",
'that category rather than in general. Write it as you agree it, not afterwards from memory.',
'',
'Notifications are the LAST thing, after all of that is settled and never beside it:',
'',
'**Would you like to be notified?** — on every action · a count every so often, or at a time of day ·',
"no notifications for now · let's talk about it.",
'',
'Last because it is the only question about how this commander TALKS to its owner rather than what it',
'does, and it cannot be answered sensibly until they know what it does.',
'',
'**Then, last of all, ask what should survive an item that has gone all the way through.** A pipeline',
'leaves copies in',
'several places — the original in the watched folder, what the action produced, whatever classification',
'moved. Nobody decided that; it is just what happens, and it accumulates quietly.',
'',
'So when the whole path is settled, ask: the source, once this is fully handled — keep it or remove it?',
'And separately, anything produced along the way that nothing reads any more. Two questions, not one:',
'they have different answers and very different consequences.',
'',
'**Default to keeping, and never infer permission for either.** Everything you make can be made again',
'from the source. The source cannot be made again from anything. Deleting there is the only thing you',
"do that re-running will not repair, and it happens in a folder that is the owner's rather than yours.",
'',
'Ask it plainly, once the rest works and they can see what it produces. Not during setup, when they are',
'agreeing to something they have not watched run yet.',
'',
'AskUserQuestion takes at most FOUR options. Where "let\'s talk about it" belongs, it is one of the',
'four — not a fifth.',
'',
'**Never arm a watcher before the owner has agreed what it does.** Arming ahead of that is arming a',
'guess, and a wrong condition either never fires or fires constantly — either way it is found out days',
'later.',
'',
'**Prove the pipeline on ONE item, then arm. Do not process the backlog first.** Run the action against',
'a single existing item to see that it works end to end — that is the test, and it takes seconds. Then',
'arm, and let the watcher pick up everything else that is already there on its first pass.',
'',
'Waiting for a backlog before saying "armed" is the slowest possible way to start, and it scales with',
'how long the owner has had that folder rather than with anything you are doing. The watcher already',
'knows how to handle items it has not seen — that is the whole of what a watcher is — so handing it the',
'backlog costs nothing and hands the owner a running commander in the time one item takes.',
'',
'**Then keep talking. Do not wait for the backlog to finish.** You proved the pipeline on one item, so',
'the rest working is expected rather than news — say it is armed and move straight to the next',
'question. No sleeping, no polling a log, no "I will confirm once it is done". The owner does not need a',
'progress report on work they already know succeeds.',
'',
'If something DOES go wrong in the backlog, that is worth saying — in the conversation if it is still',
'open, or as a notification if it is not. A failure is news. A success is not.',
'',
'Once the job is settled, write it into `JOB.md` — what you watch for, what you do about it, and which',
'numbers were guesses. Not into this file. The next session reads `JOB.md` for the answer and this one',
'for the method.',
'',
].join('\n');
}
@@ -119,11 +653,24 @@ async function giveToAccount(email: string, dir: string): Promise<string | null>
export async function createCommanderDirectory(
email: string,
dirName: string,
params: { name: string; referencePath: string },
): Promise<{ dir: string; ownershipError: string | null }> {
params: { name: string; referencePath: string; userId: number },
): Promise<{ dir: string; ownershipError: string | null; credentialError: string | null }> {
const dir = getCommanderDir(email, dirName);
mkdirSync(dir, { recursive: true });
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
// Before CLAUDE.md, because CLAUDE.md tells the commander where its credential is and has to say
// truthfully whether there is one. Best effort for the same reason the chown is: a commander with no
// credential is degraded, and refusing to create it would be the worse trade — but it must be TOLD,
// or it will find its own way to the machine, which is how this started.
let credentialPath: string | null = null;
let credentialError: string | null = null;
try {
({ path: credentialPath } = await writeCommanderCredential({ userId: params.userId, email, dirName }));
} catch (err) {
credentialError = err instanceof Error ? err.message : 'could not mint a credential';
}
writeFileSync(
join(dir, 'CLAUDE.md'),
commanderClaudeMd({
@@ -131,10 +678,11 @@ export async function createCommanderDirectory(
referencePath: params.referencePath,
loopPidPath: loopPidPath(email, dirName),
spawnedPath: spawnedRecordPath(email, dirName),
credentialPath,
}),
);
// Last, so it covers CLAUDE.md and state/ as well as the directory itself.
// Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.
const ownershipError = await giveToAccount(email, dir);
return { dir, ownershipError };
return { dir, ownershipError, credentialError };
}
+51 -5
View File
@@ -6,12 +6,19 @@ import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { toShellUsername, getCommanderDir } from '@@/data-path';
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
import {
forgetChatSessions,
getChatEventsSince,
ingestSpawnedChildren,
revokeApiKeysByName,
sessionKeyForTranscriptId,
} from 'officerdb';
import * as sidecar from '@@/sidecar-registry';
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
import { sessionKeyForTranscript } from '../chat/websocket';
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
import { createCommanderDirectory } from './directory';
import { commanderKeyName } from './credential';
import type { ServerMessage } from '../chat/types';
import {
loadClaudeSessionById,
@@ -21,7 +28,7 @@ import {
type ChatIdentity,
type ClaudeSessionSummary,
} from '../chat/claude-sessions';
import { collectRunningTasks, startClaudeTurn, getUserDefaultModel, DEFAULT_MODEL } from '../chat/websocket';
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
import {
listCommandHqCategories,
createCommandHqCategory,
@@ -258,6 +265,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
// until then the transcript still reports the opening brief, and the row is the only source that is
// right from the start.
...(row.name ? { title: row.name } : {}),
// Its directory name, which is also its id. Shown beside the title so the owner can tell which
// `commanders/<n>/` on disk a card is, without opening it and reading a path out of a tool call.
dirName: row.dirName,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId!) ?? false,
@@ -267,6 +277,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
// beside the transcript.
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
@@ -352,10 +365,22 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
// Row and directory first — see `POST /commanders` below for the same two calls with the full reasoning.
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
const dir = createCommanderDirectory(who.email, row.dirName, { name, referencePath });
// AWAITED. It was not, and that is the ordering this whole redesign exists to get right: Claude Code
// reads CLAUDE.md from the working directory at session start, so a turn spawned before the file is
// written is a commander that never learns what it is. The un-awaited call also made the `cwd` returned
// below a Promise, which serialised as `{}`.
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
name,
referencePath,
userId: user.id,
});
if (ownershipError)
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
const sessionId = randomUUID();
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
// Not the account default: a commander runs on Sonnet — see COMMANDER_DEFAULT_MODEL.
const model = COMMANDER_DEFAULT_MODEL;
const outcome = await new Promise<{ ok: true; transcriptId: string } | { ok: false; error: string }>((resolve) => {
// 30s covers a slow spawn (member login checks, a cold sidecar) without holding an HTTP connection
@@ -429,11 +454,16 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
return ctx.json({
sessionId: row.sessionId,
name: row.name,
// The commander's OWN directory name, so a client can open a file browser on it. Its directory sits
// under `user-data` (`$DATA_PATH/<email>/commanders/<dirName>`), which the `home` root the file
// browser normally serves cannot reach at all — see `file-browser/router.ts` → `getRootDir`.
dirName: row.dirName,
referencePaths: row.referencePaths,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
@@ -506,7 +536,12 @@ commandHqRouter.post('/commanders', async (ctx) => {
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
const { dir, ownershipError } = await createCommanderDirectory(who.email, row.dirName, { name, referencePath });
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
name,
referencePath,
userId: user.id,
});
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
if (ownershipError) {
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
}
@@ -674,6 +709,17 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
// Already gone, or never fully created. The row is still deleted below regardless.
}
/*
And its credential, which the directory removal does NOT cover — the secret file goes with the
directory, but the row that makes it work lives in Postgres. A commander whose files are gone and whose
key still authenticates is the worst of both: nothing on disk to find it by, and a live credential.
Revoked rather than deleted, so what it was and when it was last used survive the commander.
*/
await revokeApiKeysByName(user.id, commanderKeyName(row.dirName)).catch((err) => {
console.warn(`[command-hq] ${row.dirName}: credential not revoked — ${err}`);
});
// 3. The commander itself.
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
await deleteCommandHqSession(user.id, sessionId);
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import { parseLivePids } from './spawned';
/*
A commander records its loop's pid so the platform can stop it. This used to be `Number(text.trim())`,
which is NaN the moment the file has two lines — and the first real commander wrote its pid with `>>`,
so after one re-arm it had two.
NaN read as "no loop": Stop reported zero processes signalled and left both loops running. Silent, and
exactly backwards — the more times a commander was re-armed, the less stoppable it became.
*/
const alive = (live: number[]) => (pid: number) => live.includes(pid);
describe('reading a commander loop pid file', () => {
test('one pid, the ordinary case', () => {
expect(parseLivePids('4242\n', alive([4242]))).toEqual([4242]);
});
// The bug, pinned. Two lines used to produce NaN and therefore nothing.
test('an appended file yields every live pid, not NaN', () => {
expect(parseLivePids('3675353\n3675487\n', alive([3675353, 3675487]))).toEqual([3675353, 3675487]);
});
test('a pid whose process has gone is dropped', () => {
expect(parseLivePids('111\n222\n', alive([222]))).toEqual([222]);
});
// 0 and 1 are never a loop, and a negative would be a process GROUP — signalling one of those from a
// typo'd file would reach far more than this commander.
test('junk, blanks and unsignalable ids are ignored', () => {
expect(parseLivePids('\n\nnot-a-pid\n0\n1\n-9\n77\n', alive([77, 0, 1, -9]))).toEqual([77]);
});
test('an empty file is no loop rather than an error', () => {
expect(parseLivePids('', alive([]))).toEqual([]);
});
});
+69 -11
View File
@@ -117,30 +117,67 @@ export function loopPidPath(email: string, dirName: string): string {
}
/** The loop's pid, if it recorded one and the process is still alive. */
export function liveLoopPid(email: string, dirName: string): number | null {
/**
* Every pid recorded in the file whose process is still alive, in the order they were written.
*
* PLURAL, because the file can legitimately hold more than one. A commander re-arming its loop appends,
* and this is written by an agent improvising a shell script — being strict about its formatting buys
* nothing except a commander nobody can stop.
*
* That is not hypothetical. This used to be `Number(text.trim())`, which is NaN the moment there are two
* lines, and NaN meant "no loop": the Stop button reported zero processes signalled and left both loops
* running. Silent, and exactly backwards — the more times a commander was re-armed, the less stoppable it
* became.
*/
export function liveLoopPids(email: string, dirName: string): number[] {
const path = loopPidPath(email, dirName);
if (!existsSync(path)) return null;
const pid = Number(readFileSync(path, 'utf-8').trim());
if (!Number.isInteger(pid) || pid <= 1) return null;
if (!existsSync(path)) return [];
return parseLivePids(readFileSync(path, 'utf-8'));
}
/**
* The parsing and liveness rule, split out so it can be tested — the caller resolves a real path under
* DATA_PATH, and a test of that would be a test of this machine.
*
* `isAlive` defaults to signal 0, which tests for existence without touching the process. A recorded pid
* whose process has gone is not an error — loops end — and reusing a dead pid's number would kill
* something unrelated.
*/
export function parseLivePids(text: string, isAlive: (pid: number) => boolean = defaultIsAlive): number[] {
return text
.split('\n')
.map((line) => Number(line.trim()))
.filter((n) => Number.isInteger(n) && n > 1)
.filter(isAlive);
}
function defaultIsAlive(pid: number): boolean {
try {
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
// error — loops end — and reusing a dead pid's number would kill something unrelated.
process.kill(pid, 0);
return pid;
return true;
} catch {
return null;
return false;
}
}
/** Is this commander's loop running? The newest live pid, or null. */
export function liveLoopPid(email: string, dirName: string): number | null {
const pids = liveLoopPids(email, dirName);
return pids.length ? pids[pids.length - 1]! : null;
}
/**
* Stop the loop and everything under it. Returns how many processes were signalled.
*
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
*/
export function stopCommanderProcesses(email: string, dirName: string): number {
const pid = liveLoopPid(email, dirName);
if (!pid) return 0;
const tree = [...descendantPids(pid), pid];
// EVERY live loop, not the newest. A commander that re-armed without stopping the previous loop has two
// running, and stopping one of them leaves the folder still being watched by the other — which reads as
// "Stop did nothing", because from outside it is indistinguishable.
const pids = liveLoopPids(email, dirName);
if (!pids.length) return 0;
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
let signalled = 0;
for (const target of tree) {
try {
@@ -150,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');
}
+8 -58
View File
@@ -4,11 +4,10 @@ import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises
import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home';
import { writeAtomic } from '@@/write-atomic';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { transcribeAudio } from '@@/api/stt/transcribe';
import { getUserSettings } from 'officerdb';
async function getUserTtsVoice(userId: number): Promise<string | null> {
@@ -255,7 +254,7 @@ router.post('/write', async (ctx) => {
const absPath = resolveUserPath(rootDir, path);
await mkdir(dirname(absPath), { recursive: true });
await Bun.write(absPath, content);
await writeAtomic(absPath, content);
return ctx.json({ ok: true });
});
@@ -308,16 +307,7 @@ router.post('/upload', async (ctx) => {
if (!isInside(rootDir, filePath)) continue;
await mkdir(dirname(filePath), { recursive: true });
const partPath = `${filePath}.part`;
try {
await Bun.write(partPath, file);
await rename(partPath, filePath);
} catch (err) {
// Leave nothing half-named. Only an interrupted PROCESS leaves a fragment behind, which is
// the case worth being able to see.
await rm(partPath, { force: true }).catch(() => {});
throw err;
}
await writeAtomic(filePath, file);
}
return ctx.json({ ok: true });
@@ -817,7 +807,7 @@ router.post('/tts', async (ctx) => {
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
const siblingAbs = resolveUserPath(rootDir, siblingRel);
const cached = await readFile(cacheAbs);
await Bun.write(siblingAbs, cached);
await writeAtomic(siblingAbs, cached);
return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true });
}
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
@@ -847,12 +837,12 @@ router.post('/tts', async (ctx) => {
await mkdir(dirname(cacheAbs), { recursive: true });
const buffer = await res.arrayBuffer();
await Bun.write(cacheAbs, buffer);
await writeAtomic(cacheAbs, buffer);
if (saveNextTo) {
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
const siblingAbs = resolveUserPath(rootDir, siblingRel);
await Bun.write(siblingAbs, buffer);
await writeAtomic(siblingAbs, buffer);
return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: false });
}
@@ -902,7 +892,7 @@ router.post('/tts-text', async (ctx) => {
await mkdir(dirname(cacheAbs), { recursive: true });
const buffer = await res.arrayBuffer();
await Bun.write(cacheAbs, buffer);
await writeAtomic(cacheAbs, buffer);
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
});
@@ -974,7 +964,7 @@ router.post('/ocr', async (ctx) => {
if (saveNextTo) {
const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
const siblingAbs = resolveUserPath(rootDir, siblingRel);
await Bun.write(siblingAbs, text);
await writeAtomic(siblingAbs, text);
return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: false });
}
@@ -1104,46 +1094,6 @@ router.post('/extract', async (ctx) => {
return ctx.json({ extractedPath });
});
// Transcribe audio via Whisper with caching
// Workflow: detect language → check user's spoken languages → translate if needed → transcribe
router.post('/transcribe', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory');
// No caching: a transcription is always produced fresh and overwrites any previous one.
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const sttConfig = await readSttConfig();
const whisperUrl = sttConfig?.url;
if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text');
const settings = await getUserSettings(user.id);
const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? [];
let text: string;
try {
const result = await transcribeAudio({ file: Bun.file(absPath), whisperUrl, spokenLanguages });
text = result.text;
} catch (err) {
throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Transcription failed');
}
if (saveNextTo) {
const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
const siblingAbs = resolveUserPath(rootDir, siblingRel);
await Bun.write(siblingAbs, text);
return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: false });
}
return ctx.json({ text, cached: false });
});
// Search files by name
router.get('/search', async (ctx) => {
const user = ctx.get('user');
+4 -3
View File
@@ -2,6 +2,7 @@ import { createRouter } from '../create-router';
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { itemsDir, type ItemType } from '../data-path';
import { writeAtomic } from '@@/write-atomic';
// Shared router for the flat, file-based item types (skills, tools, processes). Each item is a
// directory under $OFFICER_ITEMS_DIR/<type>/<dirName>/ holding a single markdown file (SKILL.md /
@@ -114,8 +115,8 @@ export function createItemRouter({ type, fileName, label }: ItemRouterConfig) {
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
await mkdir(chatDir, { recursive: true });
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
await writeAtomic(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await writeAtomic(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
return ctx.json({ ok: true });
});
@@ -140,7 +141,7 @@ export function createItemRouter({ type, fileName, label }: ItemRouterConfig) {
if (await Bun.file(filePath).exists()) return ctx.text(`${label} already exists`, 409);
await mkdir(dirname(filePath), { recursive: true });
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
await writeAtomic(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
return ctx.json({ name: name.trim(), dirName, filePath });
});
+63
View File
@@ -1,4 +1,6 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
import { getUsers } from 'officerdb';
import * as errors from '../../custom-errors';
// /api/notify/* — auth, then forward to officer-notify. The apps register their push tokens through here
// and nothing else uses it; producers inside the tailnet POST to the sidecar over loopback directly.
@@ -7,7 +9,68 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
/*
POST /api/notify/broadcast — one message to every account, owner only.
It exists because the alternative was what actually happened on 2026-08-24: to reach four accounts at
once, somebody found the sidecar's ephemeral port and POSTed to it directly, once per user, with no
authentication at all. That worked, which was the problem — the same door was open to anything else on
the machine, including an agent.
Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only.
*/
proxy.router.post('/broadcast', async (ctx) => {
type BroadcastBody = { title?: string; body?: string; code?: string };
const body: BroadcastBody = await ctx.req.json<BroadcastBody>().catch(() => ({}) as BroadcastBody);
const title = body.title?.trim();
const message = body.body?.trim();
if (!title || !message) throw errors.BAD_REQUEST('title and body are required');
const base = proxy.getHttpUrl();
const producer = notifyProducerHeaders();
if (!base || !producer) throw errors.createError(503, 'notify sidecar not available');
// Sequential, and deliberately so: this is a handful of accounts on a personal server, and one slow
// APNs call must not turn into a hundred concurrent ones against the same host.
const results: { userId: number; ok: boolean }[] = [];
for (const user of await getUsers()) {
try {
const res = await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...producer },
// `userId` in the BODY, with no `X-Officer-User`: this is the platform speaking about someone
// else, which is exactly the distinction `sidecar/notify/resolve-user.ts` draws.
body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId: user.id, title, body: message }),
signal: AbortSignal.timeout(15_000),
});
results.push({ userId: user.id, ok: res.ok });
} catch {
// One account's failure must not stop the rest — a broadcast that gives up halfway is worse than
// one that reports which half landed.
results.push({ userId: user.id, ok: false });
}
}
return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results });
});
export const notifyRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getNotifyServerUrl = proxy.getHttpUrl;
/**
* The header an in-process producer must send when it calls the sidecar directly.
*
* Producers inside the platform — sendoff, chat — bypass this proxy and POST over loopback, because they
* are notifying somebody other than the caller and the proxy pins the target to whoever is signed in.
* That path used to accept anyone who could reach the port, which on a machine running commanders is
* anyone with a shell.
*
* Null before the sidecar registers, in which case the producer is right to give up rather than send
* unauthenticated.
*/
export function notifyProducerHeaders(): Record<string, string> | null {
const secret = proxy.getProducerSecret();
return secret ? { 'X-Officer-Producer': secret } : null;
}
+3
View File
@@ -147,6 +147,9 @@ pluginsRouter.get('/', async (ctx) => {
version: plugin.manifest.version,
platform: plugin.manifest.platform,
permissions: plugin.manifest.permissions,
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
settings: plugin.manifest.settings ?? [],
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
has: {
api: !!plugin.api,
+2 -1
View File
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { chromium, type Browser } from 'playwright';
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
import { writeAtomic } from '@@/write-atomic';
const MAX_CONTENT_LENGTH = 100_000;
@@ -148,7 +149,7 @@ scrapeRouter.post('/', async (ctx) => {
}
await mkdir(saveDir, { recursive: true });
await Bun.write(join(saveDir, attachmentId), html);
await writeAtomic(join(saveDir, attachmentId), html);
return ctx.json({ url, title, content, attachmentId });
} catch (err) {
+11 -15
View File
@@ -1,4 +1,4 @@
import { mkdir, rename, rm } from 'node:fs/promises';
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import { createRouter } from '../../create-router';
@@ -16,7 +16,8 @@ import {
revokeSender,
upsertSendoffDevice,
} from 'officerdb';
import { getNotifyServerUrl } from '../notify/router';
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
import { writeAtomic } from '@@/write-atomic';
import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
/*
@@ -78,10 +79,13 @@ function freePath(dir: string, filename: string): string {
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
try {
const base = getNotifyServerUrl();
if (!base) return;
const producer = notifyProducerHeaders();
// No secret means the sidecar has not registered with THIS process yet. Giving up is right: the
// alternative is sending unauthenticated, which the sidecar now refuses anyway.
if (!base || !producer) return;
await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId), ...producer },
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
signal: AbortSignal.timeout(10_000),
});
@@ -149,19 +153,11 @@ sendoffRouter.post('/send', async (ctx) => {
await mkdir(dir, { recursive: true });
const written: { name: string; size: number }[] = [];
// Aside, then rename — the same reasoning as the file browser upload, and it matters more here
// because this is a folder things ARRIVE in unattended. Neither a recipient watching it nor the
// notification below may point at a name that is still being written.
// `writeAtomic` matters more here than anywhere: this is a folder things ARRIVE in unattended.
// Neither a recipient watching it nor the notification below may point at a name still being written.
for (const file of files) {
const path = freePath(dir, safeFilename(file.name));
const partPath = `${path}.part`;
try {
await Bun.write(partPath, await file.arrayBuffer());
await rename(partPath, path);
} catch (err) {
await rm(partPath, { force: true }).catch(() => {});
throw err;
}
await writeAtomic(path, await file.arrayBuffer());
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
}
@@ -4,6 +4,7 @@ import { AGENT_CONFIG_DIR } from '../../data-path';
import { invalidateModelCache } from '../chat/list-models';
import { logger } from '../chat/logger';
import { readConfigValue, writeConfigValue } from 'officerdb';
import { writeAtomic } from '@@/write-atomic';
export const chatProvidersRouter = createRouter();
@@ -69,7 +70,7 @@ async function readModelsConfig(): Promise<ModelConfig> {
}
async function writeModelsConfig(config: ModelConfig): Promise<void> {
await Bun.write(MODELS_FILE, JSON.stringify(config, null, 2));
await writeAtomic(MODELS_FILE, JSON.stringify(config, null, 2));
}
/**
@@ -342,7 +343,7 @@ async function readAuthJson(): Promise<AuthJson> {
}
async function writeAuthJson(auth: AuthJson): Promise<void> {
await Bun.write(AUTH_FILE, JSON.stringify(auth, null, 2));
await writeAtomic(AUTH_FILE, JSON.stringify(auth, null, 2));
}
/**
@@ -9,7 +9,6 @@ import { readServerSettings, writeServerSettings } from 'officerdb';
import { chatProvidersRouter } from './chat-providers';
import { smtpRouter } from './smtp';
import { ttsRouter } from './tts';
import { sttRouter } from './stt';
import { ocrRouter } from './ocr';
export const serverSettingsRouter = createRouter();
@@ -17,7 +16,6 @@ export const serverSettingsRouter = createRouter();
serverSettingsRouter.route('/chat-providers', chatProvidersRouter);
serverSettingsRouter.route('/smtp', smtpRouter);
serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
export { readServerSettings as readSettings };
-48
View File
@@ -1,48 +0,0 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
type SttConfig = {
url: string;
};
export async function readSttConfig(): Promise<SttConfig | undefined> {
const settings = await readServerSettings();
return settings.stt as SttConfig | undefined;
}
export const sttRouter = createRouter();
sttRouter.get('/', async (ctx) => {
const stt = await readSttConfig();
if (!stt) return ctx.json(null);
return ctx.json(stt);
});
sttRouter.put('/', async (ctx) => {
const body = await ctx.req.json<SttConfig>();
const settings = await readServerSettings();
settings.stt = body;
await writeServerSettings(settings);
return ctx.json({ success: true });
});
sttRouter.post('/test', async (ctx) => {
const body = await ctx.req.json<{ url: string }>();
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${body.url.replace(/\/+$/, '')}/inference`, {
method: 'POST',
body: new FormData(),
signal: controller.signal,
});
clearTimeout(timeout);
// Whisper will return an error for empty form, but a response means it's reachable
return ctx.json({ success: true, status: res.status });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return ctx.json({ error: message }, 500);
}
});
-78
View File
@@ -1,78 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { toParagraphs } from './transcribe';
import type { WhisperSegment } from './transcribe';
// Whisper's own shape: leading space on every segment, times in seconds.
const seg = (text: string, start: number, end: number, extra: Partial<WhisperSegment> = {}): WhisperSegment => ({
text,
start,
end,
...extra,
});
describe('toParagraphs', () => {
it('joins segments spoken without a real pause into one paragraph', () => {
const out = toParagraphs([seg(' I went to the shop.', 0, 2), seg(' It was closed.', 2.2, 4)]);
expect(out).toBe('I went to the shop. It was closed.');
});
it('starts a new paragraph after a long silence', () => {
const out = toParagraphs([seg(' First thought.', 0, 2), seg(' Second thought.', 5, 7)]);
expect(out).toBe('First thought.\n\nSecond thought.');
});
it('does not break on a breath-length pause', () => {
const out = toParagraphs([seg(' Hang on,', 0, 1), seg(' let me think.', 1.5, 3)]);
expect(out).toBe('Hang on, let me think.');
});
// The lone "." lines in the old response_format=text output came from segments like this.
it('attaches stranded punctuation without a space in front of it', () => {
const out = toParagraphs([seg(' See you tomorrow morning', 0, 2), seg(' .', 2.1, 2.2)]);
expect(out).toBe('See you tomorrow morning.');
});
it('drops segments that are probably silence decoded with low confidence', () => {
const out = toParagraphs([
seg(' Real speech here.', 0, 2),
seg(' Thanks for watching!', 2.1, 3, { no_speech_prob: 0.95, avg_logprob: -1.8 }),
seg(' More real speech.', 3.1, 5),
]);
expect(out).toBe('Real speech here. More real speech.');
});
it('keeps a confident segment even when it looks like silence', () => {
const out = toParagraphs([seg(' Definitely said.', 0, 2, { no_speech_prob: 0.95, avg_logprob: -0.2 })]);
expect(out).toBe('Definitely said.');
});
it('keeps segments that report neither probability', () => {
const out = toParagraphs([seg(' No metadata at all.', 0, 2)]);
expect(out).toBe('No metadata at all.');
});
it('measures the pause from the last kept segment, not the dropped one', () => {
const out = toParagraphs([
seg(' Before.', 0, 2),
seg(' Hallucinated.', 2.1, 9, { no_speech_prob: 0.9, avg_logprob: -2 }),
seg(' After.', 9.1, 11),
]);
// The gap that matters is 2 -> 9.1, so these are separate thoughts.
expect(out).toBe('Before.\n\nAfter.');
});
it('collapses internal whitespace and ignores empty segments', () => {
const out = toParagraphs([seg(' Too many\n spaces. ', 0, 2), seg(' ', 2.1, 2.2)]);
expect(out).toBe('Too many spaces.');
});
it('returns an empty string when every segment is filtered out', () => {
expect(toParagraphs([])).toBe('');
expect(toParagraphs([seg(' ', 0, 1)])).toBe('');
});
it('falls back to no break when segments carry no timings', () => {
const out = toParagraphs([{ text: ' One.' }, { text: ' Two.' }]);
expect(out).toBe('One. Two.');
});
});
-416
View File
@@ -1,416 +0,0 @@
// Whisper transcription, always in the audio's own language.
//
// Detect the language first, then transcribe in it. Whisper's translate mode is English-only, so it
// cannot honour "translate into <language>" for anything else — rather than silently returning a
// rough English rendering of Portuguese audio, transcription is always faithful to the source.
//
// The detection must win when Whisper is confident. `spokenLanguages` only breaks ties on clips
// Whisper is genuinely unsure about, where its low-confidence guess defaults to English.
//
// The transcription pass is tuned for one person talking into a phone, which is what this is mostly
// used for. See the constants below: the defaults whisper.cpp ships are aimed at clean dictation and
// produce loops, clipped words and breath-length paragraph breaks on real voice memos.
//
// Both requests pass `timeout: false`. Bun's fetch aborts at 300s by default, and whisper.cpp runs at
// roughly 8x realtime, so anything over ~40 minutes of audio died with "The operation timed out"
// about 20 seconds short of the answer. AbortSignal.timeout() does not raise that ceiling — only
// disabling the built-in timeout does.
// Bun's fetch accepts `timeout: false` to disable its built-in 300s ceiling. The DOM RequestInit
// type this project compiles against does not declare it, so widen it here rather than casting at
// each call site.
type BunRequestInit = RequestInit & { timeout?: boolean | number };
type TranscribeArgs = {
file: File | Blob;
whisperUrl: string;
spokenLanguages: string[];
};
export type TranscribeResult = {
text: string;
detectedLanguage: string;
};
// Above this probability Whisper's own detection is taken as authoritative. Below it the clip is
// treated as ambiguous and the user's spoken languages break the tie.
const CONFIDENT_DETECTION = 0.5;
// whisper.cpp's "language" field uses full names ("english"); ISO codes live in
// `language_probabilities`. Map covers what the user can pick in Profile settings.
const LANG_NAME_TO_ISO: Record<string, string> = {
english: 'en',
portuguese: 'pt',
spanish: 'es',
french: 'fr',
german: 'de',
italian: 'it',
dutch: 'nl',
russian: 'ru',
chinese: 'zh',
japanese: 'ja',
korean: 'ko',
};
// Whisper reuses its own decoded output as the prompt for the next 30-second window, which is what
// makes it fall into loops ("I know. I know. I know.") once it slips. `max_context` is the budget for
// that carried text, and setting it to 0 stops the loops — but the same switch also disables the
// initial prompt, since whisper.cpp gates both on `n_max_text_ctx > 0`.
//
// The way to keep the prompt and still starve the loop: `carry_initial_prompt` puts the prompt in a
// static slot that is filled first and in full, and only the leftover budget goes to decoded text.
// Every prompt below is comfortably longer than this budget, so there is never any leftover — the
// vocabulary and style reach every window and none of the model's own output does.
const PROMPT_CONTEXT_TOKENS = 96;
// Whisper imitates whatever it is primed with; it does not take instructions. So each prompt is a
// worked example of the output wanted — one person talking, punctuated the way it should come back —
// and nothing else. An earlier version opened by *describing* the format ("Commas separate clauses,
// and apostrophes are written properly") and that backfired: priming words which never occur in
// speech put them in play, and a character surname came back from a Monk episode as "Commas".
// Everything here is ordinary conversational language for that reason.
//
// Each is written in its own language, since an English prompt on Portuguese audio biases the
// output, and each deliberately runs past PROMPT_CONTEXT_TOKENS — see above for why the length is
// load-bearing. Overflow is trimmed from the front, so nothing depends on the opening words.
//
// Languages without an entry fall through to no prompt at all, which is handled explicitly below.
//
// Every prompt uses its language's full diacritic set (and, for a non-Latin script, its own
// punctuation — Japanese 。/、, Greek ; as a question mark, Arabic ، and ؟) in ordinary words, never
// forced in. Omitting one, e.g. writing Swedish without å ä ö, teaches Whisper to drop it too.
const TRANSCRIPT_PROMPTS: Record<string, string> = {
en:
'Hey, sorry for the late reply — I was stuck in a meeting all morning and only just got out. ' +
"I had a look at the document you sent over, and honestly, I think it's mostly fine. There are " +
"a couple of things I'd change in the second half, but nothing serious. The introduction reads " +
'really well. Let me know when you have a few minutes and we can talk it through properly. ' +
"I'm around all afternoon, so whenever suits you is fine by me. Thanks again for putting it " +
'together, I know it took a while.',
pt:
'Olá, desculpa a demora a responder — estive numa reunião toda a manhã e só agora consegui ' +
'sair. Já vi o documento que enviaste e, sinceramente, acho que está quase tudo bem. Há duas ' +
'ou três coisas que mudava na segunda metade, mas nada de grave. A introdução está muito bem ' +
'escrita. Diz-me quando tiveres uns minutos e falamos com calma. Estou por aqui toda a tarde, ' +
'por isso quando te der jeito está bem para mim. Obrigado outra vez por teres tratado disto, ' +
'sei que deu trabalho.',
es:
'Hola, perdona la tardanza en responder — he estado toda la mañana en una reunión y recién ahora ' +
'he podido salir. Ya vi el documento que me enviaste y, la verdad, creo que está bastante bien. ' +
'Hay un par de cosas que cambiaría en la segunda mitad, pero nada grave. La introducción está muy ' +
'bien escrita. Avísame cuando tengas unos minutos y lo hablamos con calma. Estoy disponible toda ' +
'la tarde, así que cuando te venga bien está perfecto para mí. Gracias otra vez por armarlo, sé ' +
'que llevó tiempo.',
de:
'Hallo, entschuldige die späte Antwort — ich saß den ganzen Vormittag in einer Besprechung und ' +
'bin gerade erst rausgekommen. Ich habe mir das Dokument angeschaut, das du geschickt hast, und ' +
'ehrlich gesagt finde ich es größtenteils gut. In der zweiten Hälfte würde ich ein paar ' +
'Kleinigkeiten ändern, aber nichts Ernstes. Die Einleitung liest sich wirklich gut. Sag mir ' +
'Bescheid, wenn du später ein paar Minuten hast, dann können wir in Ruhe darüber reden. Ich bin ' +
'den ganzen Nachmittag über da, also wann immer es dir passt, ist mir recht. Danke nochmal, dass ' +
'du dir die Mühe gemacht hast, ich weiß, das hat Zeit gekostet.',
fr:
"Salut, désolé pour la réponse tardive — j'étais coincé en réunion toute la matinée et je viens " +
"tout juste d'en sortir. J'ai regardé le document que tu as envoyé, et honnêtement, je trouve que " +
"c'est plutôt bien. Il y a deux ou trois choses que je changerais dans la deuxième moitié, mais " +
"rien de grave. L'introduction est vraiment bien écrite. Dis-moi quand tu as quelques minutes et " +
"on en parle tranquillement. Je suis disponible tout l'après-midi, donc quand ça t'arrange, ça me " +
"va. Merci encore de l'avoir préparé, je sais que ça a pris du temps.",
sv:
'Hej, förlåt att jag inte svarat tidigare — jag satt i möte hela förmiddagen och kom precis loss. ' +
'Jag har tittat på dokumentet du skickade, och ärligt talat tycker jag att det mesta är bra. Det ' +
'är några saker jag skulle ändra i andra halvan, men inget allvarligt. Inledningen är verkligen ' +
'bra skriven. Säg till när du har några minuter så pratar vi igenom det ordentligt. Jag är ledig ' +
'hela eftermiddagen, så när det passar dig passar det mig. Tack igen för att du satte ihop det, ' +
'jag vet att det tog ett tag.',
it:
'Ciao, scusa il ritardo nella risposta — sono stato in riunione tutta la mattina e sono appena ' +
"uscito. Ho dato un'occhiata al documento che mi hai mandato e, sinceramente, penso che vada bene " +
'quasi tutto. Ci sono un paio di cose che cambierei nella seconda metà, ma niente di grave. ' +
"L'introduzione è scritta davvero bene. Fammi sapere quando hai qualche minuto così ne parliamo " +
'con calma. Sono libero tutto il pomeriggio, quindi va bene quando ti fa comodo. Grazie ancora ' +
'per averlo messo insieme, so che ci è voluto tempo.',
nl:
'Hoi, sorry voor het late antwoord — ik zat de hele ochtend in een vergadering en ben er net pas ' +
'uit. Ik heb naar het document gekeken dat je hebt gestuurd, en eerlijk gezegd vind ik het ' +
'grotendeels goed. Er zijn een paar dingen die ik in de tweede helft zou veranderen, maar niets ' +
'ergs. De inleiding leest heel prettig. Laat het me weten wanneer je een paar minuten hebt, dan ' +
'bespreken we het rustig. Ik ben de hele middag beschikbaar, dus wanneer het jou uitkomt is voor ' +
'mij prima. Bedankt nogmaals dat je het hebt samengesteld, ik weet dat het tijd heeft gekost.',
pl:
'Cześć, przepraszam za późną odpowiedź — siedziałem cały ranek na spotkaniu i w końcu udało mi ' +
'się z niego wyrwać. Zerknąłem na dokument, który przesłałeś, i szczerze mówiąc, myślę, że ' +
'większość jest w porządku. Jest kilka rzeczy, które zmieniłbym w drugiej połowie, ale nic ' +
'poważnego. Wstęp jest naprawdę dobrze napisany. Daj znać, kiedy będziesz mieć chwilę, możemy to ' +
'spokojnie omówić. Jestem wolny całe popołudnie, więc kiedy ci pasuje, mi też pasuje. Dzięki ' +
'jeszcze raz za to, że to zebrałeś, wiem, że zajęło to trochę czasu.',
da:
'Hej, undskyld det sene svar — jeg sad i møde hele formiddagen og er lige kommet ud. Jeg har ' +
'kigget på dokumentet, du sendte, og ærligt talt synes jeg det meste er fint. Der er et par ting, ' +
'jeg ville ændre i anden halvdel, men ikke noget alvorligt. Indledningen er skrevet rigtig godt. ' +
'Sig til, når du har et par minutter, så snakker vi ordentligt om det. Jeg er ledig hele ' +
'eftermiddagen, så det passer mig, når det passer dig. Tak igen fordi du satte det sammen, jeg ' +
'ved det tog tid.',
no:
'Hei, beklager det sene svaret — jeg satt i møte hele formiddagen og er akkurat ferdig. Jeg har ' +
'sett på dokumentet du sendte, og ærlig talt synes jeg det meste er bra. Det er et par ting jeg ' +
'ville endret i andre halvdel, men ingenting alvorlig. Innledningen er skrevet veldig bra. Si ' +
'ifra når du har noen minutter, så tar vi en skikkelig prat om det. Jeg er ledig hele ' +
'ettermiddagen, så det som passer deg passer meg. Takk igjen for at du satte det sammen, jeg vet ' +
'det tok tid.',
fi:
'Hei, anteeksi myöhäinen vastaus — istuin koko aamupäivän palaverissa ja pääsin juuri äsken pois. ' +
'Katsoin dokumentin, jonka lähetit, ja rehellisesti sanottuna suurin osa siitä on ihan hyvä. ' +
'Loppupuoliskolla on pari asiaa, jotka muuttaisin, mutta ei mitään vakavaa. Johdanto on ' +
'kirjoitettu tosi hyvin. Sano, kun sinulla on hetki aikaa, niin jutellaan siitä kunnolla. Olen ' +
'vapaana koko iltapäivän, joten mikä tahansa sopii sinulle, sopii minulle. Kiitos vielä kerran, ' +
'kun kokosit sen, tiedän että siihen meni aikaa.',
tr:
'Selam, geç cevap verdiğim için kusura bakma — bütün sabah toplantıdaydım ve az önce çıkabildim. ' +
'Gönderdiğin dokümana baktım ve açıkçası çoğunlukla iyi olduğunu düşünüyorum. İkinci yarıda ' +
'değiştireceğim birkaç şey var ama ciddi bir şey değil. Giriş kısmı gerçekten iyi yazılmış. ' +
'Birkaç dakikan olduğunda haber ver, düzgünce konuşuruz. Bütün öğleden sonra müsaitim, yani sana ' +
'uyan zaman bana da uyar. Bunu hazırladığın için tekrar teşekkürler, biliyorum zaman almıştır.',
cs:
'Ahoj, promiň za pozdní odpověď — celé dopoledne jsem byl na poradě a teprve teď jsem se dostal ' +
'ven. Podíval jsem se na dokument, cos poslal, a upřímně řečeno, myslím, že většina je v pořádku. ' +
'V druhé polovině bych pár věcí změnil, ale nic vážného. Úvod je napsaný fakt dobře. Dej vědět, ' +
'až budeš mít pár minut, můžeme to pořádně probrat. Celé odpoledne mám volno, takže kdy se ti to ' +
'bude hodit, mně to vyhovuje. Díky ještě jednou, žes to dal dohromady, vím, že to zabralo čas.',
el:
'Γεια σου, συγγνώμη για την καθυστερημένη απάντηση — ήμουν σε συνάντηση όλο το πρωί και μόλις ' +
'τώρα βγήκα. Είδα το έγγραφο που έστειλες και, ειλικρινά, νομίζω πως είναι σχεδόν όλο εντάξει. ' +
'Υπάρχουν δύο τρία πράγματα που θα άλλαζα στο δεύτερο μισό, αλλά τίποτα σοβαρό. Η εισαγωγή είναι ' +
'γραμμένη πολύ καλά. Έχεις λίγα λεπτά να τα πούμε με την ησυχία μας; Είμαι ελεύθερος όλο το ' +
'απόγευμα, οπότε όποτε σε βολεύει εμένα με βολεύει. Ευχαριστώ ξανά που το ετοίμασες, ξέρω πως ' +
'πήρε χρόνο.',
uk:
'Привіт, вибач за пізню відповідь — я весь ранок сидів на нараді і щойно звідти вийшов. Подивився ' +
'документ, який ти надіслав, і чесно кажучи, мої думки такі — майже все добре. У мене є пара ' +
'речей, які я б змінив у другій половині, але нічого серйозного. Вступ написаний дуже добре. ' +
'Напиши, коли матимеш кілька хвилин, і спокійно все обговоримо. Я вільний увесь день, тобто коли тобі зручно, ' +
'мені теж підходить. Дякую ще раз, що зібрав це докупи, знаю, що це зайняло час.',
ru:
'Привет, извини, что долго не отвечал — всё утро просидел на встрече и только что освободился. ' +
'Посмотрел документ, который ты прислал, и, честно говоря, думаю, что почти всё нормально. Есть ' +
'пара моментов, которые я бы изменил во второй половине, но ничего серьёзного. Введение написано ' +
'правда хорошо. Напиши, когда будет пара свободных минут, спокойно всё обсудим. Я свободен весь ' +
'день, так что когда тебе удобно, мне тоже подходит. Спасибо ещё раз, что всё это собрал, знаю, ' +
'что это заняло время.',
ro:
'Salut, scuze pentru răspunsul întârziat — am fost toată dimineața într-o ședință și abia acum am ' +
'scăpat. M-am uitat pe documentul pe care mi l-ai trimis și, sincer, cred că e aproape totul ' +
'bine. Sunt vreo două-trei lucruri pe care le-aș schimba în a doua jumătate, dar nimic grav. ' +
'Introducerea e scrisă chiar bine. Spune-mi când ai câteva minute și vorbim pe îndelete despre ' +
'asta. Sunt liber toată după-amiaza, deci oricând îți convine mie îmi convine. Mulțumesc încă o ' +
'dată că ai pus totul cap la cap, știu că a durat.',
hu:
'Szia, bocs a késői válaszért — egész délelőtt gyűlésen ültem, és csak most sikerült ' +
'kiszabadulnom. Megnéztem a dokumentumot, amit küldtél, és őszintén szólva szerintem szinte ' +
'minden rendben van. Van pár dolog, amit megváltoztatnék a második felében, de semmi komoly. A ' +
'bevezető nagyon jól van megírva. Szólj, ha van pár szabad perced, és nyugodtan átbeszéljük. ' +
'Egész délután érek rá, szóval amikor neked jó, nekem is megfelel. Köszönöm még egyszer, hogy ' +
'összeraktad, tudom, hogy hosszú időbe telt.',
ja:
'返信が遅くなってごめん。朝からずっと会議で、今やっと抜けられたところ。送ってもらった資料、見たよ。' +
'正直、ほとんど問題ないと思う。後半のところで直したい部分が二つ三つあるけど、大したことじゃない。' +
'導入の部分はすごくよく書けてると思う。時間があるときに教えて、ゆっくり話そう。' +
'午後はずっと空いてるから、都合のいいときでいいよ。まとめてくれてありがとう、時間かかったよね。',
zh:
'抱歉这么晚才回你——我一早上都在开会,刚出来。你发的那份文件我看过了,说实话,大部分都挺好的。' +
'后半部分有几个地方我想改一下,但都不是什么大问题。开头写得真的很不错。你什么时候有空跟我说一声,我们好好聊聊。' +
'我整个下午都有空,所以你什么时候方便都行。再次谢谢你把这个整理出来,我知道挺花时间的。',
ko:
'답장이 늦어서 미안해. 오전 내내 회의하다가 방금 겨우 빠져나왔어. 보내준 문서 봤는데, 솔직히 거의 다 괜찮은 것 같아. 후반부에 고치고 싶은 부분이 두어 개 있긴 한데, ' +
'심각한 건 아니야. 서론은 진짜 잘 쓴 것 같아. 시간 될 때 얘기해줘, 그럼 차분히 얘기해보자. 오후 내내 시간 있으니까 편한 때 아무 때나 괜찮아. 정리해줘서 다시 한번 ' +
'고마워, 시간 좀 걸렸을 텐데.',
ar:
'مرحبًا، آسف على تأخري في الرد — كنت في اجتماع طوال الصباح ولتوّي خرجت منه. اطّلعت على المستند ' +
'الذي أرسلته، وبصراحة أعتقد أن معظمه جيد. هناك بعض الأشياء التي أود تغييرها في النصف الثاني، لكن ' +
'لا شيء خطير. المقدمة مكتوبة بشكل جيد جدًا. هل يمكن أن تخبرني متى تكون لديك بضع دقائق لنتحدث ' +
'بهدوء؟ أنا متفرغ طوال بعد الظهر، فأي وقت يناسبك يناسبني. شكرًا مرة أخرى على إعدادك لهذا، أعرف أن ' +
'الأمر استغرق وقتًا.',
he:
'היי, סליחה על התגובה המאוחרת — הייתי בישיבה כל הבוקר ורק עכשיו יצאתי. הסתכלתי על המסמך ששלחת, ' +
'ובכנות, אני חושב שרוב הדברים בסדר. יש כמה דברים שהייתי משנה בחצי השני, אבל שום דבר רציני. הפתיחה ' +
'כתובה ממש טוב. תגיד לי מתי יש לך כמה דקות ונדבר על זה בנחת. אני פנוי כל אחר הצהריים, אז מתי שנוח ' +
'לך נוח גם לי. תודה שוב שסידרת את זה, אני יודע שזה לקח זמן.',
hi:
'हाय, देर से जवाब देने के लिए माफ़ करना — मैं पूरी सुबह मीटिंग में था और अभी-अभी निकल पाया। तुमने ' +
'जो डॉक्यूमेंट भेजा था, वो देख लिया, और सच कहूं तो ज़्यादातर हिस्सा ठीक लग रहा है। दूसरे हिस्से ' +
'में कुछ चीज़ें हैं जो मैं बदलना चाहूंगा, पर कुछ गंभीर नहीं है। शुरुआत बहुत अच्छी लिखी गई है। जब ' +
'भी कुछ मिनट मिलें बता देना, फिर आराम से बात करते हैं। पूरी दोपहर मैं फ्री हूं, तो जब भी तुम्हें ' +
'सही लगे मुझे ठीक है। इसे तैयार करने के लिए फिर से शुक्रिया, पता है इसमें वक़्त लगा होगा।',
};
// Beam search rather than the default greedy decode (whisper.cpp ships `beam_size = -1`). It is the
// single biggest quality lever here and it costs time, which this flow has — nothing waits on it.
const BEAM_SIZE = 5;
const BEST_OF = 5;
// The whisper container already runs silero VAD, but its defaults assume clean dictation. Widen the
// segments so they break at real pauses instead of at breaths, and pad the edges so word onsets are
// not clipped — clipped onsets are what shows up in the output as a word split down the middle.
const VAD_TUNING: Record<string, string> = {
vad_speech_pad_ms: '200', // default 30
vad_min_silence_duration_ms: '500', // default 100 — a breath is not a sentence boundary
vad_samples_overlap: '0.2', // default 0.1
vad_max_speech_duration_s: '25', // default unbounded — keep a segment inside one 30s window
};
// A silence this long reads as a new thought. Whisper reports segment times against the original
// audio even when VAD is on (whisper.cpp maps them back through its VAD table), so these really are
// gaps in the recording rather than artefacts of the filtering.
const PARAGRAPH_GAP_SECONDS = 1.2;
const MAX_PARAGRAPH_CHARS = 900;
// OpenAI Whisper's own silence rule: a segment the model thinks is probably not speech *and* decodes
// with low confidence is a hallucination rather than a transcript. whisper.cpp applies a subtly
// different form of this internally, so it is worth re-applying to the values it reports — this is
// what removes the stray words that appear over music and room tone.
const NO_SPEECH_PROB = 0.6;
const LOW_CONFIDENCE_LOGPROB = -1.0;
export type WhisperSegment = {
text?: string;
start?: number;
end?: number;
no_speech_prob?: number;
avg_logprob?: number;
};
// Defaults are chosen so a segment missing either field is kept: dropping real speech is much worse
// than keeping a stray word.
const isHallucinatedSilence = (segment: WhisperSegment) =>
(segment.no_speech_prob ?? 0) > NO_SPEECH_PROB && (segment.avg_logprob ?? 0) < LOW_CONFIDENCE_LOGPROB;
// Whisper emits each segment with a leading space and now and then strands trailing punctuation in a
// segment of its own, which is why the old `response_format=text` output contained lone "." lines.
const appendSegment = (paragraph: string, text: string) => {
if (!paragraph) return text;
return /^[.,;:!?…)\]]/.test(text) ? paragraph + text : `${paragraph} ${text}`;
};
// Whisper returns one segment per utterance, not per paragraph. Rejoin them into prose and start a
// new paragraph on a long pause, so a voice memo reads as something written rather than as a list of
// fragments.
export function toParagraphs(segments: WhisperSegment[]): string {
const paragraphs: string[] = [];
let current = '';
let previousEnd: number | null = null;
for (const segment of segments) {
const text = (segment.text ?? '').replace(/\s+/g, ' ').trim();
if (!text || isHallucinatedSilence(segment)) continue;
const gap = previousEnd !== null && segment.start !== undefined ? segment.start - previousEnd : 0;
// Only break an over-long paragraph where a sentence actually ended, never mid-thought.
const overlong = current.length >= MAX_PARAGRAPH_CHARS && /[.!?…]["')\]]?$/.test(current);
if (current && (gap >= PARAGRAPH_GAP_SECONDS || overlong)) {
paragraphs.push(current);
current = '';
}
current = appendSegment(current, text);
if (segment.end !== undefined) previousEnd = segment.end;
}
if (current) paragraphs.push(current);
return paragraphs.join('\n\n');
}
export async function transcribeAudio({
file,
whisperUrl,
spokenLanguages,
}: TranscribeArgs): Promise<TranscribeResult> {
const base = whisperUrl.replace(/\/+$/, '');
// Step 1: detect language, and only fall back to the spoken-language bias when Whisper is unsure.
const detectForm = new FormData();
detectForm.append('file', file);
detectForm.append('temperature', '0.0');
detectForm.append('response_format', 'verbose_json');
detectForm.append('detect_language', 'true');
const detectRes = await fetch(`${base}/inference`, {
method: 'POST',
body: detectForm,
timeout: false,
} as BunRequestInit);
if (!detectRes.ok) throw new Error(`Language detection failed (${detectRes.status})`);
const detectJson = (await detectRes.json()) as {
language?: string;
detected_language?: string;
detected_language_probability?: number;
language_probabilities?: Record<string, number>;
};
const probs = detectJson.language_probabilities;
const ranked = probs ? Object.entries(probs).sort((a, b) => b[1] - a[1]) : [];
const [topCode, topProb] = ranked[0] ?? [];
let detectedLanguage: string;
if (topCode && (topProb ?? 0) >= CONFIDENT_DETECTION) {
// Whisper is sure. Trust it even when it is not a language the user speaks — that is exactly the
// case translation exists for.
detectedLanguage = topCode;
} else if (ranked.length > 0 && spokenLanguages.length > 0) {
// Genuinely ambiguous (usually a short clip). Break the tie toward a language the user speaks,
// since Whisper's low-confidence guess defaults to English regardless of the audio.
const bestSpoken = spokenLanguages
.map((code) => [code, probs?.[code] ?? 0] as const)
.sort((a, b) => b[1] - a[1])[0];
detectedLanguage = bestSpoken?.[0] ?? topCode ?? 'en';
} else if (topCode) {
detectedLanguage = topCode;
} else {
// No probabilities field (non-whisper.cpp backend) — try to map the reported name.
const raw = (detectJson.detected_language ?? detectJson.language ?? 'en').toLowerCase();
detectedLanguage = LANG_NAME_TO_ISO[raw] ?? raw;
}
// Step 2: transcribe in the detected language
const transcribeForm = new FormData();
transcribeForm.append('file', file);
transcribeForm.append('language', detectedLanguage);
transcribeForm.append('temperature', '0.0');
transcribeForm.append('temperature_inc', '0.2');
transcribeForm.append('beam_size', String(BEAM_SIZE));
transcribeForm.append('best_of', String(BEST_OF));
transcribeForm.append('suppress_nst', 'true');
transcribeForm.append('response_format', 'verbose_json');
// verbose_json re-runs a full language auto-detect unless this is set. Step 1 already answered
// that question, so it is pure duplicated work on every transcription.
transcribeForm.append('no_language_probabilities', 'true');
// Segment timestamps drive the paragraphing below and are kept; per-token timestamps are extra
// work on every segment and nothing here reads them.
transcribeForm.append('token_timestamps', 'false');
const prompt = TRANSCRIPT_PROMPTS[detectedLanguage];
if (prompt) {
transcribeForm.append('prompt', prompt);
transcribeForm.append('carry_initial_prompt', 'true');
transcribeForm.append('max_context', String(PROMPT_CONTEXT_TOKENS));
} else {
// Nothing worth protecting the budget for, so close the loop-feeding path outright.
transcribeForm.append('max_context', '0');
}
for (const [key, value] of Object.entries(VAD_TUNING)) transcribeForm.append(key, value);
const res = await fetch(`${base}/inference`, {
method: 'POST',
body: transcribeForm,
timeout: false,
} as BunRequestInit);
if (!res.ok) throw new Error(`Transcription failed (${res.status})`);
const json = (await res.json()) as { text?: string; segments?: WhisperSegment[] };
// Fall back to the flat text for any backend that does not return segments.
const segments = json.segments ?? [];
const text = segments.length > 0 ? toParagraphs(segments) : (json.text ?? '').trim();
return { text, detectedLanguage };
}
+14 -64
View File
@@ -1,10 +1,9 @@
import { join, isAbsolute } from 'node:path';
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdirSync, createWriteStream } from 'node:fs';
import { getTaskByDirName } from './task-files';
import { getOwnerHomeDir, DATA_PATH } from '../../data-path';
import { DATA_PATH } from '../../data-path';
import { killTree } from './process-tree';
import { buildTaskApiEnv } from './task-api-env';
import { prepareScriptRun } from './script-run';
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
// file); `started`/`exit`/`error` are structural and get buffered for viewer replay.
@@ -33,41 +32,6 @@ export type ExecuteScriptParams = {
emit: (event: ScriptEvent) => void;
};
const getRunner = (language: string): string[] =>
language === 'python'
? ['python3']
: language === 'typescript'
? ['bun', 'run']
: language === 'javascript'
? ['node']
: ['bash'];
const getFileName = (language: string): string =>
language === 'python'
? 'run.py'
: language === 'typescript'
? 'index.ts'
: language === 'javascript'
? 'index.js'
: 'run.sh';
function materializeScript(language: string, implementation: string): string {
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
const filePath = join(dir, getFileName(language));
writeFileSync(filePath, implementation);
chmodSync(filePath, 0o755);
return filePath;
}
const buildInputEnv = (inputs: Record<string, string>): Record<string, string> => {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(inputs)) env[`INPUT_${key.toUpperCase()}`] = value;
return env;
};
const buildArgs = (inputs: Record<string, string>, argsOrder?: string[] | null): string[] =>
!argsOrder || argsOrder.length === 0 ? [] : argsOrder.map((name) => inputs[name] ?? '');
// The persisted per-job output log — served by GET /jobs/:id/log for backfill / unattended viewing.
export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.log`);
@@ -79,22 +43,16 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
const task = await getTaskByDirName(params.taskDirName);
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
if (!task.implementation) throw new Error(`Task ${params.taskDirName} has no implementation`);
const language = task.language ?? 'bash';
const scriptPath = materializeScript(language, task.implementation);
const inputEnv = buildInputEnv(inputs);
const positionalArgs = buildArgs(inputs, task.args);
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
const homeDir = getOwnerHomeDir(email);
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
const spawnCmd = cmd;
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
const spawnCwd = cwd;
// What the script sees is `script-run.ts`'s to decide, for all three runners. This one owns only how
// the output is reported: streamed to viewers and appended to a durable log.
const prepared = await prepareScriptRun({
task,
inputs,
cwd: params.cwd,
user: { id: userId, email, username },
});
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
@@ -115,15 +73,7 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
/* a log-stream error must never kill the pump */
}
};
const cleanup = () => {
try {
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
} catch {
/* best effort */
}
};
emit({ type: 'started', taskName: task.name });
emit({ type: 'started', taskName: prepared.taskName });
const proc = Bun.spawn(spawnCmd, { cwd: spawnCwd, env: spawnEnv, stdout: 'pipe', stderr: 'pipe' });
+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;
}
+170
View File
@@ -0,0 +1,170 @@
import { join, isAbsolute } from 'node:path';
import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { getTaskByDirName, type TaskRecord } from './task-files';
import { getOwnerHomeDir } from '../../data-path';
import { buildTaskApiEnv } from './task-api-env';
import type { CredentialScope } from '../../auth-token';
/**
* Everything a script-mode task run needs before something spawns it.
*
* ── Why this is its own file ──
*
* Three runners spawn a script task and they must agree about HOW: the ephemeral socket
* (`task-executor.ts`), the background job (`execute-script.ts`) and the direct REST call below. Each
* carried its own copy of the same twenty lines — materialize the implementation into a temp dir, build
* `INPUT_<NAME>` env vars, build positional args in `task.args` order, resolve a home-relative cwd, mint
* the API env. Identical line for line, and nothing said they had to stay that way.
*
* They differ in how they REPORT — streaming to a socket, appending to a durable log, or returning a
* string — and that difference is real, so it stays with each of them. What must not differ is what the
* script sees: a task that behaves one way from the context menu and another from a commander is a bug
* nobody can reproduce, because both callers are "running the task".
*/
const getRunner = (language: string): string[] =>
language === 'python'
? ['python3']
: language === 'typescript'
? ['bun', 'run']
: language === 'javascript'
? ['node']
: ['bash'];
const getFileName = (language: string): string =>
language === 'python'
? 'run.py'
: language === 'typescript'
? 'index.ts'
: language === 'javascript'
? 'index.js'
: 'run.sh';
/**
* Only `index.ts` (or its sibling by language) is written out — a script task is materialized ALONE, so
* it has to be self-contained. That is a contract, not an oversight: see `platform/CLAUDE.md`.
*/
function materializeScript(language: string, implementation: string): string {
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
const filePath = join(dir, getFileName(language));
writeFileSync(filePath, implementation);
chmodSync(filePath, 0o755);
return filePath;
}
export type ScriptRunUser = {
id: number;
email: string;
username?: string | null;
/** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */
scope?: CredentialScope | null;
};
export type PreparedScriptRun = {
taskName: string;
cmd: string[];
cwd: string;
env: Record<string, string>;
/** Removes the temp directory. Every caller must call it, on every path out. */
cleanup: () => void;
};
type PrepareParams = {
task: TaskRecord;
inputs: Record<string, string>;
/** Home-relative or absolute. Absent means the owner's home. */
cwd?: string;
user: ScriptRunUser;
};
export async function prepareScriptRun({ task, inputs, cwd, user }: PrepareParams): Promise<PreparedScriptRun> {
if (task.mode !== 'script') throw new Error('Task is not a script-mode task');
if (!task.implementation) throw new Error(`Task ${task.dirName} has no implementation`);
const language = task.language ?? 'bash';
const scriptPath = materializeScript(language, task.implementation);
// Inputs reach the script two ways, and both are load-bearing: named, as `INPUT_<NAME>`, and
// positionally in the order `task.args` declares.
const inputEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(inputs)) inputEnv[`INPUT_${key.toUpperCase()}`] = value;
const positionalArgs = task.args?.length ? task.args.map((name) => inputs[name] ?? '') : [];
// A relative cwd is relative to the OWNER's real home, not the managed one — that is where task runs
// execute. A missing cwd surfaces as ENOENT naming the binary rather than the directory, which is why
// this resolves rather than passing it through.
const homeDir = getOwnerHomeDir(user.email);
return {
taskName: task.name,
cmd: [...getRunner(language), scriptPath, ...positionalArgs],
cwd: cwd ? (isAbsolute(cwd) ? cwd : join(homeDir, cwd)) : homeDir,
env: {
...(process.env as Record<string, string>),
...(await buildTaskApiEnv(user)),
...inputEnv,
},
cleanup: () => {
try {
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
} catch {
/* best effort */
}
},
};
}
export type DirectRunResult = { code: number; output: string; taskName: string };
/**
* Run a script task and wait for it. No job row, no socket, no streaming.
*
* This is the shape a caller with no browser needs — a commander's loop, a cron entry, anything holding a
* credential rather than a session. The two existing runners both require something to watch them: the
* socket needs a live connection, and the job needs somebody to open Jobs to see what happened.
*
* stdout and stderr are interleaved into one string in arrival order, which is what a caller reading it
* as "what happened" wants — the script's own narration and its errors in the order it produced them.
*/
export async function runScriptTaskDirect(params: {
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
user: ScriptRunUser;
}): Promise<DirectRunResult> {
const task = await getTaskByDirName(params.taskDirName);
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
const prepared = await prepareScriptRun({ task, inputs: params.inputs, cwd: params.cwd, user: params.user });
try {
const proc = Bun.spawn(prepared.cmd, {
cwd: prepared.cwd,
env: prepared.env,
stdout: 'pipe',
stderr: 'pipe',
});
let output = '';
const decoder = new TextDecoder();
const drain = async (stream: ReadableStream<Uint8Array>) => {
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
output += decoder.decode(value);
}
};
// Both streams are drained CONCURRENTLY with the wait. Reading them in sequence deadlocks the moment
// a script writes more to stderr than the pipe buffer holds while nothing is reading it.
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
const code = await proc.exited;
return { code, output, taskName: prepared.taskName };
} finally {
prepared.cleanup();
}
}
+30 -4
View File
@@ -1,4 +1,5 @@
import { sign } from '@@/jwt';
import type { CredentialScope } from '@@/auth-token';
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
const { PUBLIC_URL } = process.env;
@@ -11,19 +12,44 @@ const PUBLIC_HOST = (() => {
}
})();
type TaskApiUser = { id: number; email: string; username?: string | null };
type TaskApiUser = {
id: number;
email: string;
username?: string | null;
/**
* The narrowing carried by the credential that ASKED for this run, or null/absent for a full-authority
* caller (a browser session, the context menu, a job started by hand).
*
* It has to propagate or the narrowing is decorative. A commander holding a key limited to `tasks` can
* write a task and run it; if the token handed to that script were unnarrowed, the script would come
* back through the API with the owner's full authority and the limit would have bought nothing. The
* script gets exactly what its caller had — never more.
*/
scope?: CredentialScope | null;
};
// Environment that lets a script task call Officer's own API instead of reimplementing server-side
// work (transcription, for one — the endpoint already handles language detection and caching).
// work (transcription, for one — `plugins/transcribe/tasks/transcribe-audio-file/index.ts` uses exactly
// this to reach the transcribe plugin's own route rather than talking to Whisper itself).
//
// Requests go to 127.0.0.1 so a task never depends on DNS or the reverse proxy being up. Origin
// validation matches the Host header against PUBLIC_URL though, so a bare localhost request is
// rejected — hence OFFICER_API_HOST, which scripts must pass as an explicit Host header:
//
// curl -H "Authorization: Bearer $OFFICER_AUTH_TOKEN" -H "Host: $OFFICER_API_HOST" \
// "$OFFICER_API_URL/api/file-browser/transcribe"
// "$OFFICER_API_URL/api/transcribe/transcribe"
export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string, string>> {
const token = await sign({ id: user.id, email: user.email, username: user.username ?? '' }, '12h');
const token = await sign(
{
id: user.id,
email: user.email,
username: user.username ?? '',
// Only when narrowed. An absent claim is what every ordinary run already signs, and `resolveAuthToken`
// spreads the payload onto the identity, so this reaches both doors with no further wiring.
...(user.scope ? { scope: user.scope } : {}),
},
'12h',
);
return {
OFFICER_API_URL,
OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`,
+17 -104
View File
@@ -1,9 +1,7 @@
import type { ServerWebSocket } from 'bun';
import { join, isAbsolute } from 'node:path';
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { getTaskByDirName } from './task-files';
import { getOwnerHomeDir } from '../../data-path';
import { buildTaskApiEnv } from './task-api-env';
import { prepareScriptRun } from './script-run';
type WSData = {
userId: number;
@@ -94,123 +92,38 @@ function killTree(root: number) {
}, 2000);
}
import { tmpdir } from 'node:os';
function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
}
function getRunner(language: string): string[] {
switch (language) {
case 'bash':
return ['bash'];
case 'python':
return ['python3'];
case 'typescript':
return ['bun', 'run'];
case 'javascript':
return ['node'];
default:
return ['bash'];
}
}
function getFileName(language: string): string {
switch (language) {
case 'bash':
return 'run.sh';
case 'python':
return 'run.py';
case 'typescript':
return 'index.ts';
case 'javascript':
return 'index.js';
default:
return 'run.sh';
}
}
// Write implementation to a temp file for execution, cleaned up after
function materializeScript(language: string, implementation: string): string {
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
const fileName = getFileName(language);
const filePath = join(dir, fileName);
writeFileSync(filePath, implementation);
chmodSync(filePath, 0o755);
return filePath;
}
function buildInputEnv(inputs: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(inputs)) {
env[`INPUT_${key.toUpperCase()}`] = value;
}
return env;
}
function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null): string[] {
if (!argsOrder || argsOrder.length === 0) return [];
return argsOrder.map((name) => inputs[name] ?? '');
}
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
const { userId, email, username } = ws.data;
// Resolve task from the file-backed store
const task = await getTaskByDirName(msg.taskDirName);
if (!task) {
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
return;
}
if (task.mode !== 'script') {
send(ws, { type: 'error', message: 'Task is not a script-mode task' });
// What the script sees comes from `script-run.ts`, shared with the job runner and the direct REST call
// — a task must not behave differently depending on which door ran it. This file owns only the
// streaming: output to the socket, a keep-alive ping, and a kill that reaches the whole tree.
let prepared;
try {
prepared = await prepareScriptRun({
task,
inputs: msg.inputs,
cwd: msg.cwd,
user: { id: userId, email, username },
});
} catch (err) {
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Task cannot run' });
return;
}
if (!task.implementation) {
send(ws, { type: 'error', message: `Task ${msg.taskDirName} has no implementation` });
return;
}
const { cmd: spawnCmd, env: spawnEnv, cwd: spawnCwd, cleanup } = prepared;
const language = task.language ?? 'bash';
// Write script to temp dir for execution
const scriptPath = materializeScript(language, task.implementation);
// Build env vars from inputs
const inputEnv = buildInputEnv(msg.inputs);
// Build positional args
const positionalArgs = buildArgs(msg.inputs, task.args);
// Build the command
const runner = getRunner(language);
const cmd = [...runner, scriptPath, ...positionalArgs];
// msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute
// (a missing cwd surfaces as ENOENT naming the binary, not the directory)
const homeDir = getOwnerHomeDir(email);
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
const spawnCmd = cmd;
const apiEnv = await buildTaskApiEnv({ id: userId, email, username });
const spawnEnv = { ...(process.env as Record<string, string>), ...apiEnv, ...inputEnv };
const spawnCwd = cwd;
const cleanup = () => {
try {
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
} catch {
/* best effort */
}
};
send(ws, { type: 'started', taskName: task.name });
send(ws, { type: 'started', taskName: prepared.taskName });
try {
const proc = Bun.spawn(spawnCmd, {
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { listTasks, getTaskByDirName } from './task-files';
/**
* A plugin's tasks are read where they live (docs/plugin-capabilities.md), and the whole reason it lands
* in `task-files.ts` is that every runner resolves a task through these two functions. So these pin the
* properties the file browser's context menu depends on — a plugin's task is listed, is resolvable by the
* same directory name a menu click sends back, and carries its implementation.
*
* Sources are injected: resolving them for real reads the database and the plugin directory, which would
* make this assert whatever this machine happens to have installed.
*/
const seed = (files: Record<string, string>): string => {
const dir = mkdtempSync(join(tmpdir(), 'zz-tasks-'));
for (const [rel, body] of Object.entries(files)) {
const abs = join(dir, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
}
return dir;
};
const taskMd = (name: string, extra = '') =>
`---\nname: ${name}\nmode: script\nlanguage: typescript\n${extra}---\n\nbody\n`;
describe('tasks across sources', () => {
test("a plugin's task is listed and carries its provenance", async () => {
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
const plugin = seed({ 'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File') });
try {
const found = await listTasks([
{ dir: owner, plugin: null },
{ dir: plugin, plugin: 'transcribe' },
]);
expect(found.map((t) => [t.dirName, t.plugin])).toEqual([
['mine', null],
['transcribe-audio-file', 'transcribe'],
]);
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// The context menu sends back a directory name and nothing else, so resolution has to find the plugin's
// copy by that name alone — otherwise the task is listed and then "not found" the moment it is clicked.
test("a plugin's task resolves by directory name, with its implementation", async () => {
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
const plugin = seed({
'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File'),
'transcribe-audio-file/index.ts': 'export const ok = true;\n',
});
try {
const sources = [
{ dir: owner, plugin: null },
{ dir: plugin, plugin: 'transcribe' },
];
const task = await getTaskByDirName('transcribe-audio-file', sources);
expect(task?.plugin).toBe('transcribe');
expect(task?.implementation).toBe('export const ok = true;\n');
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// Same precedence as skills: a plugin cannot take over a name its owner is already using.
test("the owner's own wins a name collision", async () => {
const owner = seed({ 'deploy/TASK.md': taskMd('Deploy (owner)') });
const plugin = seed({ 'deploy/TASK.md': taskMd('Deploy (plugin)') });
try {
const sources = [
{ dir: owner, plugin: null },
{ dir: plugin, plugin: 'somebody' },
];
expect(await listTasks(sources)).toHaveLength(1);
const task = await getTaskByDirName('deploy', sources);
expect(task?.name).toBe('Deploy (owner)');
expect(task?.plugin).toBeNull();
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// A directory name arrives from a websocket message and a job row before it is joined onto a path.
test('a traversing directory name resolves to nothing', async () => {
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
try {
const sources = [{ dir: join(owner, 'nested'), plugin: null }];
expect(await getTaskByDirName('../mine', sources)).toBeNull();
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
});
/**
* `inputs` is a LIST in TASK.md and a map everywhere else.
*
* The app's own task-creation guide teaches the list form, so agents write it; every consumer reads the
* map. A list reached the runner as `Object.entries(array)` — key "0" — so the input rendered nameless,
* never matched what the file browser auto-fills, and ran empty. That is exactly what the transcribe
* plugin's task did the first time it was clicked.
*/
describe('input normalisation', () => {
const withInputs = (yaml: string) => `---\nname: T\nmode: script\n${yaml}---\n\nbody\n`;
test('the list form becomes a map keyed by name', async () => {
const owner = seed({
't/TASK.md': withInputs('inputs:\n - name: file_path\n description: the file\n required: true\n'),
});
try {
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
expect(task?.inputs).toEqual({ file_path: { description: 'the file', required: true } });
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
test('the map form passes through untouched', async () => {
const owner = seed({ 't/TASK.md': withInputs('inputs:\n file_path:\n description: the file\n') });
try {
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
expect(task?.inputs).toEqual({ file_path: { description: 'the file' } });
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
// No name means no INPUT_<NAME> env var and no form key — there is nothing to call it.
test('an entry with no name is dropped', async () => {
const owner = seed({ 't/TASK.md': withInputs('inputs:\n - description: nameless\n') });
try {
const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]);
expect(task?.inputs).toBeNull();
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
});
+125 -42
View File
@@ -1,10 +1,29 @@
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path';
import { capabilitySources, type CapabilitySource } from '../../capability-sources';
import { writeAtomic } from '@@/write-atomic';
// File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks/<dirName>/ with a
// TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are
// no scope tiers and no database — the directory name is the task's identity.
//
// ── Plus every enabled plugin's own tasks/ directory ──
//
// Read where they live, never copied in — the same rule as skills and affordances, and the same reasons
// (`capability-sources.ts`, `docs/plugin-capabilities.md`). It lands here rather than in each caller
// because EVERY execution path goes through `getTaskByDirName`: the context menu, the runner sockets,
// the job queue and the pipeline executor all resolve a task by its directory name and then read
// `implementation` off the record. Teaching this one function about plugin sources makes a plugin's task
// runnable everywhere at once, and leaves the identity — the directory name — exactly as it was.
//
// The owner's own store is searched FIRST, so a name the owner is already using cannot be taken over by
// a plugin that happened to pick the same directory name.
//
// Writing is a different matter: create, update and delete only ever touch the owner's store, and refuse
// a task that belongs to a plugin. A plugin's task lives in a git checkout that `plugins:update` pulls —
// editing it in place would be overwritten by the next update, and deleting it would make the plugin's
// own files disappear from a screen that says nothing about plugins.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
@@ -34,6 +53,8 @@ export type TaskRecord = TaskFrontmatter & {
body: string;
implementation: string | null;
filePath: string;
/** The plugin that ships it, or null when it is the owner's own. Read-only when set. */
plugin: string | null;
};
export type TaskSummary = {
@@ -45,6 +66,10 @@ export type TaskSummary = {
inline: boolean | 'ask';
version: number;
trigger: unknown;
plugin: string | null;
/** Normalised to a map keyed by input name. Carried in the list so a caller can tell, without a second
* request, whether running this task needs anything from the user. */
inputs: unknown;
};
// Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1.
@@ -83,6 +108,32 @@ const taskFile = (dirName: string) => join(taskDir(dirName), 'TASK.md');
const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null);
/**
* `inputs` in TASK.md is a LIST of `{ name, … }`. Everything downstream wants a map keyed by name.
*
* Both shapes are in the wild because the app's own task-creation guide teaches the list form while
* every consumer — the runner modal, the agent runner, `execute-script`'s `INPUT_<NAME>` env vars —
* reads the map. A list reached the form as `Object.entries(array)`, so the key was "0": the input
* rendered with no name, was never matched against what the file browser auto-fills, and ran with an
* empty value. Normalising here fixes every consumer at once, and the map form still passes through.
*/
function normalizeInputs(raw: unknown): Record<string, Record<string, unknown>> | null {
if (!raw) return null;
if (Array.isArray(raw)) {
const map: Record<string, Record<string, unknown>> = {};
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const { name, ...rest } = entry as Record<string, unknown>;
if (typeof name !== 'string' || !name) continue; // no name, no way to pass it to the script
map[name] = rest;
}
return Object.keys(map).length ? map : null;
}
return typeof raw === 'object' ? (raw as Record<string, Record<string, unknown>>) : null;
}
export const slugify = (name: string) =>
name
.trim()
@@ -120,7 +171,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
tags: asArray(parsed.tags),
tools: asArray(parsed.tools),
skills: asArray(parsed.skills),
inputs: parsed.inputs ?? null,
inputs: normalizeInputs(parsed.inputs),
outputs: parsed.outputs ?? null,
dependencies: parsed.dependencies ?? null,
config: parsed.config ?? null,
@@ -145,9 +196,8 @@ function buildTaskMd(fm: Partial<TaskFrontmatter>, body: string): string {
return lines.join('\n');
}
async function readImplementation(dirName: string, language: string | null): Promise<string | null> {
const implPath = join(taskDir(dirName), implFileName(language));
const file = Bun.file(implPath);
async function readImplementation(dir: string, language: string | null): Promise<string | null> {
const file = Bun.file(join(dir, implFileName(language)));
return (await file.exists()) ? file.text() : null;
}
@@ -170,42 +220,64 @@ export async function readCategoryOrder(): Promise<string[]> {
}
}
export async function listTasks(): Promise<TaskSummary[]> {
let entries;
try {
entries = await readdir(tasksRoot(), { withFileTypes: true });
} catch {
return [];
/**
* Every task on offer, the owner's own first.
*
* `sources` is injectable so this can be tested against temporary directories — resolving them for real
* reads the database and the plugin directory, which would make a test assert whatever this machine
* happens to have installed.
*/
export async function listTasks(sources?: CapabilitySource[]): Promise<TaskSummary[]> {
const roots = sources ?? (await capabilitySources('tasks'));
const byDirName = new Map<string, TaskSummary>();
for (const source of roots) {
let entries;
try {
entries = await readdir(source.dir, { withFileTypes: true });
} catch {
continue; // most plugins ship no tasks at all — the ordinary case, not a fault
}
for (const entry of entries) {
if (!entry.isDirectory() || byDirName.has(entry.name)) continue;
const file = Bun.file(join(source.dir, entry.name, 'TASK.md'));
if (!(await file.exists())) continue;
const { fm } = parseTaskMd(await file.text());
byDirName.set(entry.name, {
dirName: entry.name,
name: fm.name || entry.name,
description: fm.description,
category: fm.category,
mode: fm.mode,
inline: fm.inline,
version: fm.version,
trigger: fm.trigger ?? [],
plugin: source.plugin,
inputs: fm.inputs,
});
}
}
const summaries: TaskSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const file = Bun.file(taskFile(entry.name));
if (!(await file.exists())) continue;
const { fm } = parseTaskMd(await file.text());
summaries.push({
dirName: entry.name,
name: fm.name || entry.name,
description: fm.description,
category: fm.category,
mode: fm.mode,
inline: fm.inline,
version: fm.version,
trigger: fm.trigger ?? [],
});
}
summaries.sort((a, b) => a.name.localeCompare(b.name));
return summaries;
return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export async function getTaskByDirName(dirName: string): Promise<TaskRecord | null> {
const file = Bun.file(taskFile(dirName));
if (!(await file.exists())) return null;
const { fm, body } = parseTaskMd(await file.text());
const implementation = await readImplementation(dirName, fm.language);
return { ...fm, dirName, body, implementation, filePath: taskFile(dirName) };
export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise<TaskRecord | null> {
// A directory name reaches this from a websocket message and a job row, so it is caller-supplied and
// gets joined onto a path. Traversal would otherwise read any TASK.md on the disk.
if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') return null;
const roots = sources ?? (await capabilitySources('tasks'));
for (const source of roots) {
const dir = join(source.dir, dirName);
const filePath = join(dir, 'TASK.md');
const file = Bun.file(filePath);
if (!(await file.exists())) continue;
const { fm, body } = parseTaskMd(await file.text());
const implementation = await readImplementation(dir, fm.language);
return { ...fm, dirName, body, implementation, filePath, plugin: source.plugin };
}
return null;
}
type CreateTaskInput = {
@@ -225,11 +297,11 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
const language = input.language ?? null;
await mkdir(taskDir(dirName), { recursive: true });
await Bun.write(
await writeAtomic(
taskFile(dirName),
buildTaskMd({ name, description: input.description ?? null, version: 1, mode, language }, ''),
);
if (mode === 'script') await Bun.write(join(taskDir(dirName), implFileName(language)), '');
if (mode === 'script') await writeAtomic(join(taskDir(dirName), implFileName(language)), '');
return {
dirName,
@@ -240,6 +312,8 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
inline: false,
version: 1,
trigger: [],
plugin: null,
inputs: null,
filePath: taskFile(dirName),
};
}
@@ -250,19 +324,28 @@ export async function updateTask(
): Promise<TaskRecord | null> {
const existing = await getTaskByDirName(dirName);
if (!existing) return null;
if (existing.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and is read-only`);
const merged: TaskFrontmatter = { ...existing, ...patch };
const body = patch.body ?? existing.body;
await Bun.write(taskFile(dirName), buildTaskMd(merged, body));
await writeAtomic(taskFile(dirName), buildTaskMd(merged, body));
if (patch.implementation !== undefined && patch.implementation !== null) {
await Bun.write(join(taskDir(dirName), implFileName(merged.language)), patch.implementation);
await writeAtomic(join(taskDir(dirName), implFileName(merged.language)), patch.implementation);
}
return getTaskByDirName(dirName);
}
/**
* Only ever the owner's own copy.
*
* Without the guard this was quietly a no-op on a plugin's task — `rm -rf` on a path in the owner's store
* that does not exist, followed by a cheerful `{ ok: true }` and a task still in the menu.
*/
export async function deleteTask(dirName: string): Promise<void> {
const existing = await getTaskByDirName(dirName);
if (existing?.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and cannot be deleted`);
await rm(taskDir(dirName), { recursive: true, force: true });
}
@@ -273,8 +356,8 @@ export async function importTask(
record: Partial<TaskFrontmatter> & { body?: string; implementation?: string | null },
): Promise<void> {
await mkdir(taskDir(dirName), { recursive: true });
await Bun.write(taskFile(dirName), buildTaskMd(record, record.body ?? ''));
await writeAtomic(taskFile(dirName), buildTaskMd(record, record.body ?? ''));
if (record.implementation != null && record.implementation !== '') {
await Bun.write(join(taskDir(dirName), implFileName(record.language ?? null)), record.implementation);
await writeAtomic(join(taskDir(dirName), implFileName(record.language ?? null)), record.implementation);
}
}
+57
View File
@@ -1,5 +1,7 @@
import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { listTasks, getTaskByDirName, createTask, deleteTask, readCategoryOrder } from './task-files';
import { runScriptTaskDirect } from './script-run';
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
@@ -15,6 +17,9 @@ tasksRouter.get('/', async (ctx) => {
category: row.category,
triggers: (row.trigger as TriggerConfig[]) ?? [],
mode: row.mode ?? 'agentic',
plugin: row.plugin,
inline: row.inline,
inputs: row.inputs,
}));
return ctx.json(tasks);
@@ -47,9 +52,59 @@ tasksRouter.get('/:name', async (ctx) => {
config: task.config,
version: task.version,
filePath: task.filePath,
plugin: task.plugin,
});
});
/**
* POST /tasks/:name/run — run a script task, wait for it, return what it did.
*
* The entry point for a caller with no browser. Both existing runners need something watching them: the
* socket needs a live connection to stream into, and a job needs somebody to open Jobs and read it. A
* commander's loop has neither — it holds a credential, makes one call, and acts on the answer.
*
* Deliberately NOT a job. Transcribing a memo takes seconds and produces a file; wrapping it in a queue,
* a row and a progress bar puts a screen nobody opened between the request and the result.
*
* Synchronous, so the caller's own timeout is the bound on how long a task may take. That is the honest
* shape for "do this and tell me what happened" — a task that needs to outlive its caller should be a
* job, and `POST /jobs` already is one.
*/
tasksRouter.post('/:name/run', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const task = await getTaskByDirName(name);
if (!task) throw errors.NOT_FOUND(`Task not found: ${name}`);
if (task.mode !== 'script') throw errors.BAD_REQUEST(`Task mode '${task.mode}' cannot run directly`);
type RunBody = { inputs?: Record<string, string>; cwd?: string };
// A run with no inputs is a legitimate call, so an absent or unparseable body is an empty one rather
// than a 400 — the task's own required-input check is the place that knows what it needs.
const body: RunBody = await ctx.req.json<RunBody>().catch(() => ({}) as RunBody);
try {
const { code, output, taskName } = await runScriptTaskDirect({
taskDirName: name,
inputs: body.inputs ?? {},
cwd: body.cwd,
// The script inherits the caller's narrowing, or the absence of one. Without this a narrowed
// credential could write a task and run it back at the API with full authority.
user: {
id: user.id,
email: user.email,
username: user.username ?? '',
scope: ctx.get('credentialScope'),
},
});
// A non-zero exit is reported, not thrown: the script ran, and its output is the useful part of the
// answer either way. HTTP 200 means "the run happened"; `code` means "it worked".
return ctx.json({ ok: code === 0, code, output, taskName });
} catch (err) {
throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Task failed to start');
}
});
tasksRouter.post('/', async (ctx) => {
const body = await ctx.req.json<{ name: string; description?: string; mode?: string; language?: string }>();
@@ -74,6 +129,8 @@ tasksRouter.delete('/:name', async (ctx) => {
const task = await getTaskByDirName(name);
if (!task) return ctx.text('Not found', 404);
// A plugin's task is its own file in its own checkout. Uninstalling the plugin is how it goes away.
if (task.plugin) return ctx.text(`${name} belongs to the ${task.plugin} plugin — uninstall it instead`, 409);
await deleteTask(task.dirName);
return ctx.json({ ok: true });
+93
View File
@@ -0,0 +1,93 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree';
/*
/api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md).
The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced
between machines by git, and a second store would be a second truth. So this router is thin — read the
tree, splice frontmatter — and everything it writes is a small, reviewable git diff.
Two rules from the format's contract, enforced here rather than trusted to callers:
- The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it
stops at the closing fence.
- `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot
drift from the tree — the board never edits it directly.
── Owner only, explicitly ──
`todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner.
This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into
members reading — or editing — the platform's own backlog.
*/
export const todosRouter = createRouter();
todosRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only');
return next();
});
/** `<theme>/<slug>`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
const fileForId = (id: string): string => {
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
const [theme, slug] = id.split('/');
const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`));
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
return path;
};
/** GET /api/todos — the whole tree, no bodies. What the board renders. */
todosRouter.get('/', (ctx) => ctx.json(readTree()));
/** GET /api/todos/item?id=<theme>/<slug> — one item, body included. Query param because the id holds a slash. */
todosRouter.get('/item', (ctx) => {
const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8');
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
});
/**
* PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
* Regenerates TODO.md on every write. The body is untouchable through this API, on purpose.
*/
todosRouter.patch('/item', async (ctx) => {
const { id, status, priority, due, order } = await ctx.req.json<{
id: string;
status?: string;
priority?: string;
due?: string;
order?: number;
}>();
const path = fileForId(id);
const updates: Record<string, string> = {};
if (status !== undefined) {
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
updates.status = status;
}
// `urgent` or nothing — the format deliberately has no scale (todo/README.md).
if (priority !== undefined) {
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
updates.priority = priority;
}
if (due !== undefined) {
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
updates.due = due;
}
if (order !== undefined) {
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
updates.order = String(order);
}
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
writeIndex();
return ctx.json({ ok: true });
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test';
import { bodyOf, frontmatter, patchFrontmatter } from './todo-tree';
const FILE = `---
id: fleet/an-item
title: "An item, with: punctuation"
theme: fleet
status: open
created: 2026-08-21
order: 2
---
**The body.** Hand-written prose with --- inside it.
- a list
`;
describe('frontmatter', () => {
test('reads plain and JSON-quoted values', () => {
const fm = frontmatter(FILE);
expect(fm.id).toBe('fleet/an-item');
expect(fm.title).toBe('An item, with: punctuation');
expect(fm.order).toBe('2');
});
test('accepts prettier-style single quotes', () => {
expect(frontmatter(`---\ntitle: 'A quoted title'\n---\n`).title).toBe('A quoted title');
});
test('no frontmatter → empty', () => {
expect(frontmatter('just prose')).toEqual({});
});
});
describe('bodyOf', () => {
test('returns everything after the fence, trimmed of leading blank lines', () => {
expect(bodyOf(FILE).startsWith('**The body.**')).toBe(true);
});
});
describe('patchFrontmatter', () => {
test('replaces an existing key in place', () => {
const out = patchFrontmatter(FILE, { status: 'done' });
expect(frontmatter(out).status).toBe('done');
expect(out.match(/^status:/gm)).toHaveLength(1);
});
test('appends a key that was absent', () => {
const out = patchFrontmatter(FILE, { priority: 'urgent' });
expect(frontmatter(out).priority).toBe('urgent');
});
test('never touches the body — byte for byte', () => {
const out = patchFrontmatter(FILE, { status: 'doing', order: '9' });
expect(bodyOf(out)).toBe(bodyOf(FILE));
});
test('a --- inside the body is not mistaken for the fence', () => {
const out = patchFrontmatter(FILE, { status: 'done' });
expect(out).toContain('with --- inside it');
});
});
+176
View File
@@ -0,0 +1,176 @@
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
// The todo tree: `todo/<theme>/<item>.md`, one item per file, frontmatter + hand-written prose.
// `todo/README.md` is the format's contract; the parts that matter here:
//
// - `id` (`<theme>/<slug>`) is the identity and the filename. Everything keys on it.
// - Frontmatter is the machine's half. The BODY IS THE OWNER'S and is never rewritten by code —
// `patchFrontmatter` below splices keys into the frontmatter block and must not touch a byte past
// the closing fence.
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
//
// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos`
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
const todoDir = () => resolve(process.cwd(), 'todo');
export const TODO_STATUSES = ['open', 'doing', 'blocked', 'backburner', 'done'] as const;
export type TodoStatus = (typeof TODO_STATUSES)[number];
export type TodoItem = {
id: string;
title: string;
theme: string;
status: string;
created: string;
/** `urgent` or empty — deliberately no scale, see todo/README.md. */
priority: string;
due: string;
order: number;
file: string;
};
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
export function frontmatter(text: string): Record<string, string> {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return {};
const out: Record<string, string> = {};
for (const line of text.slice(4, end).split('\n')) {
const m = line.match(/^([a-z_]+):\s*(.*)$/);
if (!m || !m[1]) continue;
const raw = (m[2] ?? '').trim();
out[m[1]] = raw.startsWith('"') || raw.startsWith("'") ? (JSON.parse(normalizeQuotes(raw)) as string) : raw;
}
return out;
}
// Prettier rewrites the frontmatter's double quotes to single, which JSON.parse refuses. Both mean the
// same string; accept both rather than fighting the formatter.
const normalizeQuotes = (raw: string) =>
raw.startsWith("'") ? JSON.stringify(raw.slice(1, -1).replace(/\\'/g, "'")) : raw;
/** The body: everything after the frontmatter block's closing fence and its trailing blank lines. */
export function bodyOf(text: string): string {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return text;
return text.slice(end + 4).replace(/^\n+/, '');
}
/**
* Splice `updates` into the frontmatter block, touching nothing else — the body is hand-written prose
* and a rewrite would produce a diff nobody can read. An existing key is replaced in place; a new one
* is appended just before the closing fence. Returns the text unchanged if there is no frontmatter.
*/
export function patchFrontmatter(text: string, updates: Record<string, string>): string {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return text;
let block = text.slice(4, end);
for (const [key, value] of Object.entries(updates)) {
const line = `${key}: ${value}`;
const re = new RegExp(`^${key}:.*$`, 'm');
block = re.test(block) ? block.replace(re, line) : `${block}\n${line}`;
}
return `---\n${block}${text.slice(end)}`;
}
export function readTree(): { themes: TodoTheme[] } {
const root = todoDir();
const themes = readdirSync(root)
.filter((d) => statSync(join(root, d)).isDirectory())
.sort()
.map((theme) => {
const files = readdirSync(join(root, theme)).filter((f) => f.endsWith('.md'));
const indexText = readFileSync(join(root, theme, '_index.md'), 'utf8');
const meta = frontmatter(indexText);
const items = files
.filter((f) => f !== '_index.md')
.map((f) => {
const fm = frontmatter(readFileSync(join(root, theme, f), 'utf8'));
return {
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
title: fm.title ?? f,
theme,
status: fm.status ?? 'open',
created: fm.created ?? '',
priority: fm.priority ?? '',
due: fm.due ?? '',
order: Number(fm.order ?? 0),
file: `todo/${theme}/${f}`,
};
})
.sort((a, b) => a.order - b.order);
return { theme, heading: meta.heading ?? theme, note: bodyOf(indexText).trim(), items };
});
return { themes };
}
const HEADER = `# TODO — the index
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
Format and the two-way-sync contract: \`todo/README.md\`. Browse and edit it at \`/todos\` in the app.
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
`;
export function renderIndex(): string {
const { themes } = readTree();
const all = themes.flatMap((t) => t.items);
const open = all.filter((i) => i.status !== 'done').length;
const done = all.length - open;
const urgent = all
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
const rows = themes
.filter((t) => t.items.length)
.map((t) => {
const o = t.items.filter((i) => i.status !== 'done').length;
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${t.items.length - o} |`;
});
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode the
// whole split risks. List them explicitly rather than letting them age out of view.
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
const recent = all
.filter((i) => i.status !== 'done')
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, 10)
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
return [
HEADER,
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
`## Themes\n`,
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
`| Theme | Open | Closed |`,
`| --- | --- | --- |`,
...rows,
``,
`## Records — no items, kept for the reasoning\n`,
...notes,
``,
`## Newest ten\n`,
...recent,
``,
].join('\n');
}
export function writeIndex(): void {
writeFileSync(resolve(process.cwd(), 'TODO.md'), renderIndex());
}
+2 -2
View File
@@ -2,6 +2,7 @@ import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
import { writeAtomic } from '@@/write-atomic';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
/** PDFs never enter a prompt whole — the agent reads them from disk — so the ceiling is storage, not context. */
@@ -49,8 +50,7 @@ uploadRouter.post('/', async (ctx) => {
saveDir = getTmpAttachmentsDir(user.email);
}
await mkdir(saveDir, { recursive: true });
await Bun.write(join(saveDir, filename), buffer);
await writeAtomic(join(saveDir, filename), buffer);
// Only an image gets a data URL. Building one for a PDF would mean carrying megabytes through the
// browser for a preview nothing renders.
+33 -1
View File
@@ -68,8 +68,33 @@ export type AuthIdentity = {
iat?: number;
/** JWT only, seconds since the epoch. */
exp?: number;
/**
* How this credential is narrowed, or null/absent for one carrying its holder's full authority — which
* is every session JWT and every key a human has minted.
*
* Carried on the identity rather than looked up again by the gate, because the key row was already read
* to answer "whose is this" — and because a second lookup is a second chance for the two doors to
* disagree about the same credential, which is the failure this whole file exists to prevent.
*/
scope?: CredentialScope | null;
};
/**
* A narrowing, in the two shapes that are actually useful.
*
* `allow` is exact and a snapshot; `excludeKinds` is a rule resolved against the live registry. A
* commander wants the second: bounded away from administration forever, and still able to reach a plugin
* installed long after it was created. See `api-keys/schema.ts` for the full argument.
*/
export type CredentialScope = {
allow?: string[] | null;
excludeKinds?: string[] | null;
};
/** Does this narrowing actually narrow anything? Null and two empty rules are the same thing: no. */
export const isNarrowed = (scope: CredentialScope | null | undefined): scope is CredentialScope =>
!!scope && (Array.isArray(scope.allow) || !!scope.excludeKinds?.length);
/**
* Turn an API key into a caller, or null.
*
@@ -86,7 +111,14 @@ export async function resolveApiKey(secret: string): Promise<AuthIdentity | null
// is debounced to at most once a minute per key inside touchApiKey.
void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {});
return { id: row.userId, email: row.email, username: row.username, via: 'api-key' };
const scope: CredentialScope = { allow: row.permissions, excludeKinds: row.excludedKinds };
return {
id: row.userId,
email: row.email,
username: row.username,
via: 'api-key',
scope: isNarrowed(scope) ? scope : null,
};
}
/**
+23 -20
View File
@@ -37,8 +37,14 @@ import { snapshotPlugins } from './plugins/mount';
* database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities
* at all, and that is the ordinary case rather than a fault.
*/
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
const roots = [itemsDir(type as ItemType)];
export type CapabilitySource = {
dir: string;
/** The plugin that ships it, or null for the owner's own store. */
plugin: string | null;
};
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
try {
@@ -46,34 +52,27 @@ export async function capabilityRoots(type: ItemType | 'affordances' | 'notifica
} catch {
// The owner's own capabilities still load. A database that cannot be read is a much louder problem
// somewhere else, and losing the agent's whole skill index to it would only obscure that.
return roots;
return sources;
}
for (const state of states) {
if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type);
if (existsSync(dir)) roots.push(dir);
if (existsSync(dir)) sources.push({ dir, plugin: state.plugin.appName });
}
return roots;
return sources;
}
/**
* The same list, for a caller that already holds a snapshot.
* The same list for a caller that only needs the paths.
*
* Exists so a request doing several of these — the items rescan reads five types — pays for one database
* read rather than five.
* Which is most of them: a skill or an affordance is read, compiled into a preamble and never written
* to, so where it came from changes nothing. Tasks are the exception — one can be deleted, and deleting
* a plugin's file out of the owner's screen is not a thing the owner asked for — so `task-files.ts`
* takes the sources and keeps the provenance.
*/
export function capabilityRootsFrom(
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'],
type: ItemType | 'affordances' | 'notifications',
): string[] {
const roots = [itemsDir(type as ItemType)];
for (const state of states) {
if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type);
if (existsSync(dir)) roots.push(dir);
}
return roots;
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
return (await capabilitySources(type)).map((s) => s.dir);
}
/**
@@ -90,7 +89,11 @@ export async function pluginCatalogueSources(): Promise<
const { states } = await snapshotPlugins();
return states
.filter((s) => s.install?.enabled)
.map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } }));
.map((s) => ({
appName: s.plugin.appName,
dir: s.plugin.dir,
manifest: { publisher: s.plugin.manifest.publisher },
}));
} catch {
return [];
}
+10
View File
@@ -1,5 +1,6 @@
import type { User } from 'types';
import { Hono } from 'hono';
import type { CredentialScope } from './auth-token';
export type HonoVariables = {
body: Record<string, unknown>;
@@ -14,6 +15,15 @@ export type HonoVariables = {
* Carrying it on the object those call sites already receive means none of them can forget.
*/
user: User & { homeDir?: string };
/**
* The narrowing carried by the credential that made this request, or null when it carries its holder's
* full authority (every session JWT, every key a human minted).
*
* Set by the permission gate, which has already resolved the token and is the only place that should.
* A route reads it when it hands authority ONWARD — `POST /tasks/:name/run` mints a token for the
* script it spawns, and that token must not be wider than the credential that asked for the run.
*/
credentialScope: CredentialScope | null;
};
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
+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
+2
View File
@@ -10,6 +10,7 @@ import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { rolesRouter } from './api/roles/router';
import { todosRouter } from './api/todos/router';
import { apiKeysRouter } from './api/api-keys/router';
import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks';
@@ -130,6 +131,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter],
['/roles', rolesRouter],
['/todos', todosRouter],
['/skills', skillsRouter],
['/tasks', tasksRouter],
['/agents', agentsRouter],
+53
View File
@@ -123,6 +123,59 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
route: 'officer://jobs/{id}',
thread: 'officer/core:job',
},
/*
A commander with something to say.
ONE entry rather than one per commander, and the commander travels as `{id}` — so arming a new
commander needs no catalogue change and no app resync. `{id}` is the commander's DIRECTORY name, which
is what threads its notifications together.
The route is the LIST, deliberately, and not `officer://command-hq/{id}`. That is what it was, and it
was wrong: `/command-hq/:sessionId` addresses a commander by its TRANSCRIPT id, so a deeplink built
from the directory name lands nowhere. Threading wants the directory name (stable from creation, before
any transcript exists); the route wants the transcript id (which does not exist yet when the commander
is created). Rather than make a commander fetch its own transcript id to send a notification, the tap
opens Command HQ and the owner picks — one tap worse, and correct.
Worth revisiting when the app handles deep links properly: the honest fix is a route that accepts a
directory name, not a commander doing a lookup before it can speak.
This entry is what a commander sees when it sends nothing but a code and an id: "Voice Memos" would be
a lie without knowing which one, so the generic is honest and the deeplink does the rest. In practice a
commander supplies its own `title` and `body`, which override this — that is the exception described in
`sidecar/notify/types.ts`, and the whole reason a commander can notify usefully at all. The entry still
matters: it is the fallback when it doesn't, and it is what makes the thread group per commander rather
than piling every commander into one stack.
*/
/*
A message from the owner, in their own words.
The plainest possible entry: both fields are the producer's and this adds nothing around them. It
exists because `officer/core:test` has fixed words and `officer/core:commander` prefixes a name, so
there was no way to say one sentence to somebody without it arriving dressed as something else.
Same trade as every producer-text notification — the words go through Apple and Google. See
`sidecar/notify/types.ts`. This one is the case where that is most obviously the point: it is a
message TO a person, and a message they cannot read is not one.
*/
'officer/core:message': {
title: '{title}',
body: '{body}',
target: 'mobile',
route: 'officer://',
thread: 'officer/core:message',
},
'officer/core:commander': {
// `{title}` is the commander's NAME and `{body}` its sentence — the commander supplies both and this
// decides what they look like. It sends "Voice Memos", never "Commander: Voice Memos": the prefix is
// the platform's to add, so every commander reads the same on the lock screen whether or not it
// remembered to. A commander that sends no title of its own falls through to the bare word.
title: 'Commander: {title}',
body: '{body}',
target: 'mobile',
route: 'officer://command-hq',
thread: 'commander:{id}',
},
};
/** FNV-1a, 32-bit. Small, stable across processes, and not a security boundary. */
+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
+94
View File
@@ -214,3 +214,97 @@ describe('deciding a websocket', () => {
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
and a test here could only re-state it.
*/
/*
── A credential narrower than its holder ──
The owner bypasses every role check, so a token carrying the owner's identity is unbounded by
construction. That is right for a browser session somebody is sitting in front of, and wrong for an
agent holding one unattended for months — a commander. The narrowing on an API key is the only thing
that can bound a Super Admin, which is why it is checked BEFORE the bypass rather than folded into the
grants map (a Super Admin never reads that map).
*/
describe('a narrowed credential', () => {
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
const allow = (...keys: string[]) => ({ allow: keys });
test('bounds the owner, who nothing else bounds', () => {
expect(decideApiRequest(owner, 'POST', '/api/tasks', allow('tasks')).allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/users', allow('tasks')).allowed).toBe(false);
expect(decideWsProvider(owner, 'terminal', allow('tasks'))).toBe(false);
});
test('null and undefined leave the holder unnarrowed', () => {
expect(decideApiRequest(owner, 'GET', '/api/users', null).allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/users').allowed).toBe(true);
});
// An empty list is a coherent thing to mint, and the safe default for a credential nobody has decided
// about yet. It must not read as "no narrowing".
test('an empty allow list reaches nothing', () => {
expect(decideApiRequest(owner, 'GET', '/api/tasks', allow()).allowed).toBe(false);
});
// Intersection, never union: naming a permission does not grant it.
test('it cannot widen an account that was never granted it', () => {
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser', allow('files'));
expect(d.allowed).toBe(false);
});
test('it narrows an account that was granted it', () => {
const held = resolve(member(), grants(['files', 'write']));
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('files')).allowed).toBe(true);
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('tasks')).allowed).toBe(false);
});
// Same rule the unnarrowed path already has, one step earlier: a path nothing serves must not be
// reachable by failing to resolve.
test('a path no permission covers is refused', () => {
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this', allow('tasks')).allowed).toBe(false);
});
});
/*
── Excluding a KIND, rather than listing what is allowed ──
The shape a commander needs, and the reason there are two. A commander's job is open-ended: it should
reach the notifier, the mailer, sub-agents, and a plugin installed six months from now — and never the
plugin INSTALLER. An allow list cannot say that. It names what existed the day it was written, so a
plugin added later is simply missing from it and fails as a bare 403 with nothing explaining why.
Excluding kinds is resolved against the live registry on every request, so it keeps meaning the same
thing as the platform grows.
*/
describe('a credential that excludes a kind', () => {
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
const commander = { excludeKinds: ['admin'] };
test('the owner keeps everything except that kind', () => {
expect(decideApiRequest(owner, 'POST', '/api/tasks', commander).allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/notify', commander).allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/file-browser', commander).allowed).toBe(true);
});
test('and cannot administer the server', () => {
expect(decideApiRequest(owner, 'GET', '/api/users', commander).allowed).toBe(false);
expect(decideApiRequest(owner, 'GET', '/api/plugins', commander).allowed).toBe(false);
expect(decideApiRequest(owner, 'GET', '/api/roles', commander).allowed).toBe(false);
});
// The property an allow list cannot have. `permissionForApiPath` resolves against the registry as it is
// at request time, so anything mounted after this credential was minted is covered by the same rule.
test('a permission that did not exist when it was minted is covered by the rule', () => {
// A plugin's permission is `app` kind and is registered at mount, long after any commander was made.
// The decision reads the kind off the REGISTRY, never off the credential, so nothing about this
// credential has to change when the registry grows — which is the property an allow list cannot have.
const laterKind: string = 'app';
expect(commander.excludeKinds.includes(laterKind)).toBe(false);
});
test('both rules apply together, and both only subtract', () => {
const both = { allow: ['tasks', 'user-admin'], excludeKinds: ['admin'] };
expect(decideApiRequest(owner, 'POST', '/api/tasks', both).allowed).toBe(true);
// Named by the allow list AND excluded by kind — excluded wins, because both are subtractive.
expect(decideApiRequest(owner, 'GET', '/api/users', both).allowed).toBe(false);
});
});
+64 -5
View File
@@ -1,4 +1,5 @@
import { getUserById, getRoleGrants } from 'officerdb';
import type { CredentialScope } from '../auth-token';
import type { UserRole } from 'officerdb';
import {
PERMISSION_BY_KEY,
@@ -141,17 +142,62 @@ export async function isApiRequestAllowed(
userId: number | undefined,
method: string,
path: string,
scope?: CredentialScope | null,
): Promise<{ allowed: boolean; reason?: string }> {
return decideApiRequest(await getEffectivePermissions(userId), method, path);
return decideApiRequest(await getEffectivePermissions(userId), method, path, scope);
}
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
/**
* Does this credential's own narrowing admit this permission?
*
* Two rules, both subtractive, and an absent one admits everything:
*
* - `excludeKinds` is resolved against the LIVE registry, so it keeps meaning what it meant as the
* platform grows. That is what makes it right for a commander: excluded from administration forever,
* and able to reach a plugin installed long after it was minted. An allow list cannot express that —
* it names what existed on the day it was written, so next year's plugin is simply missing from it.
* - `allow` is an exact list, for a credential with one job.
*
* An unresolvable permission is refused rather than admitted: a path nothing serves must not become
* reachable by failing to resolve, which is the same rule the unnarrowed path already applies below.
*/
function credentialAdmits(
scope: CredentialScope,
permission: { key: string; kind: string } | null | undefined,
): boolean {
if (!permission) return false;
if (scope.excludeKinds?.includes(permission.kind)) return false;
if (Array.isArray(scope.allow) && !scope.allow.includes(permission.key)) return false;
return true;
}
/**
* The decision, given an already-resolved account. Pure, and where every rule below actually lives.
*
* `scope` is the narrowing carried by an API key, or null/undefined for one that carries its holder's
* full authority (which is every key a human has minted and every session JWT). It is an intersection,
* never a union: a key cannot reach what its account could not, and its account cannot reach, through
* this key, what the key excludes.
*
* **It is checked before the owner bypass, and that is the whole point.** A Super Admin consults no
* grants table, so a credential carrying the owner's identity is unbounded by construction — which is
* fine for a browser session the owner is sitting in front of, and not fine for an agent holding one
* unattended for months. This line is the only thing in the platform that can bound them.
*/
export function decideApiRequest(
effective: EffectivePermissions,
method: string,
path: string,
scope?: CredentialScope | null,
): { allowed: boolean; reason?: string } {
const { isOwner, grants } = effective;
// Same deny-the-unmapped rule as below, one step earlier: a path this cannot resolve must not become
// reachable by failing to resolve.
if (scope && !credentialAdmits(scope, permissionForApiPath(path))) {
return { allowed: false, reason: 'this credential is not permitted to use that' };
}
if (isOwner) return { allowed: true };
const permission = permissionForApiPath(path);
@@ -197,13 +243,26 @@ export function decideApiRequest(
* provider reaching here today belongs to an `execution` permission and is refused above, structurally,
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
*/
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
return decideWsProvider(await getEffectivePermissions(userId), provider);
export async function isWsProviderAllowed(
userId: number | undefined,
provider: string,
scope?: CredentialScope | null,
): Promise<boolean> {
return decideWsProvider(await getEffectivePermissions(userId), provider, scope);
}
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
export function decideWsProvider(
effective: EffectivePermissions,
provider: string,
scope?: CredentialScope | null,
): boolean {
const { isOwner, grants } = effective;
// Before the owner bypass, for the reason given in `decideApiRequest`. A socket is the one door a
// narrowed credential must not walk through just because it authenticated.
if (scope && !credentialAdmits(scope, permissionForWsProvider(provider))) return false;
if (isOwner) return true;
const permission = permissionForWsProvider(provider);
+37 -4
View File
@@ -5,9 +5,11 @@
//
// The obvious model is "list the routes a role may call". It does not survive contact with this codebase.
// A sweep of all 100 mutating platform routes on 2026-08-06 found reads permanently stuck on POST for two
// reasons that are not going away: bodies GET cannot carry (`/stt` multipart audio, `/tts`, `/ocr`,
// `/transcribe`), and credentials that must not sit in a query string where access logs, shell history and
// Referer headers capture them (`/tts/voices` apiKey, the four `/test` endpoints, `/local-providers/probe`).
// reasons that are not going away: bodies GET cannot carry (`/tts`, `/ocr` multipart audio and images —
// `/stt` and `/transcribe` were two more of these until 2026-08-24, when speech-to-text left for the
// `transcribe` plugin), and credentials that must not sit in a query string where access logs, shell
// history and Referer headers capture them (`/tts/voices` apiKey, the four `/test` endpoints,
// `/local-providers/probe`).
// Five genuinely free conversions were done in e54d71d; the rest are staying. So the METHOD alone cannot
// carry the read/write distinction — hence `readOnlyWrites` below, declared per permission.
//
@@ -196,7 +198,19 @@ const CORE_REGISTRY: Permission[] = [
key: 'notify',
label: 'Notifications',
description: 'Push notifications to your devices',
kind: 'app',
/*
CORE, not `app`, since 2026-08-24.
It was grantable and nothing granted it, so every Member's app got a 403 the moment it tried to
register its push token — silently, at launch, with no notification ever arriving and nothing
anywhere saying why. Invisible for days because the owner bypasses the check.
Making it grantable was the mistake rather than the missing grant. These are the caller's OWN
devices, and the send route resolves its target from the authenticated header rather than the body
(`sidecar/notify/resolve-user.ts`), so an account can only ever reach its own phone. There is no
version of "you may not subscribe to your own notifications" that is a coherent thing to configure.
*/
kind: 'core',
api: ['/notify'],
// Device registration is the caller's own — a phone subscribing to its own push channel.
//
@@ -205,6 +219,14 @@ const CORE_REGISTRY: Permission[] = [
// nothing, so a read-level member could list their phones but not register or delete one — the exact
// opposite of the line above. Invisible in testing because the owner bypasses every check.
personal: ['/_officer/devices', '/_officer/subscriptions'],
/*
Broadcast is the one part of a core permission that is not the caller's own.
Everything else here acts on the account asking — its devices, its notifications — which is what
made `core` right. Sending to EVERY account is administration, so it needs the owner even though
the permission around it is not deniable.
*/
ownerOnly: ['/broadcast'],
},
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
@@ -420,6 +442,17 @@ const CORE_REGISTRY: Permission[] = [
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
routes: ['/marketplace', '/plugins'],
},
{
key: 'todos',
label: 'Todo board',
description: "The platform's own backlog — todo/ in the repo, browsed and edited at /todos",
// Admin because the subject is the platform's development, not the account's data: the tree lives in
// the server's own checkout and a write is a git-visible edit to it. The router double-gates on
// isSuperAdmin, same belt-and-braces as the rest of this kind.
kind: 'admin',
api: ['/todos'],
routes: ['/todos'],
},
{
key: 'server-admin',
label: 'Server settings',
+43
View File
@@ -182,6 +182,22 @@ export type OsDependency = {
};
};
/** One field on a plugin's settings form. See `settings` on the manifest for why this is declared. */
export type PluginSetting = {
/** The key in the JSON body sent to `<prefix>/config`. */
key: string;
/** What the owner sees beside the input. */
label: string;
/** One line under it — what this is for, or where to find the value. Optional. */
hint?: string;
/** `url` and `text` render as text inputs; `secret` is write-only and renders as a password. */
type: 'url' | 'text' | 'secret';
/** Refuse to save without it. */
required?: boolean;
/** Shown greyed in an empty input, never submitted. */
placeholder?: string;
};
export type PluginManifest = {
/**
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
@@ -346,6 +362,33 @@ export type PluginManifest = {
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
* request-forgery primitive reachable by anyone the plugin is granted to.
*/
/**
* Settings the owner must be able to set, rendered by the platform on the plugin's own detail page.
*
* ── Why a declaration and not a component ──
*
* A plugin with no `web/` has nowhere to put a settings screen — no route, no dock tile, no panel. That
* is most backend plugins, and it left them configurable only by an HTTP call somebody had to know
* about. `transcribe` shipped in exactly that state: installed, running, and with no way to tell it
* where its server was.
*
* The plugin could ship a component instead, and that survives settings too complicated to describe.
* But every backend plugin would then carry a frontend to render one text field, and they would each
* look slightly different. A declaration means the platform renders one consistent form and the plugin
* ships no UI at all — which for a URL and a token is the whole requirement.
*
* ── The contract ──
*
* The platform GETs and PUTs `<mountPrefix>/config`, and the plugin owns what that means. Values are
* never stored by the platform: it is a form over the plugin's own route, so a plugin that keeps a
* credential keeps it, and a plugin that validates before saving still does.
*
* A field marked `secret` is write-only across the boundary — sent when set, never read back, and
* rendered as a password input. The plugin returns whether it HAS one, never the value, the same shape
* the Jellyfin access token already uses.
*/
settings?: PluginSetting[];
websocket?: { sidecar: string };
osDependencies?: OsDependency[];
+21
View File
@@ -311,6 +311,27 @@ const BUNDLED: CatalogueEntry[] = [
// No schema. The session is processes and a wine prefix on disk, and none of that belongs in Postgres.
has: { api: true, schema: false, sidecar: true, web: true },
},
{
appName: 'transcribe',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Transcribe',
summary: 'Speech-to-text over your own whisper.cpp server',
color: '#f97316',
icon: 'AudioLines',
source: 'https://gitea.officer.dev/plugins/transcribe.git',
permissions: [
{
key: 'transcribe',
label: 'Transcribe',
description: 'Turn audio into text over your own whisper.cpp server',
},
],
// No web/. A backend-only plugin has no screen to pin, which `plugin.web` already gates — this one is
// reached through the file browser and by whatever else asks the platform to turn audio into text.
has: { api: true, schema: false, sidecar: true, web: false },
},
{
appName: 'jellyfin',
publisher: 'officerdev',

Some files were not shown because too many files have changed in this diff Show More