the catalogue was the only way a remote plugin could arrive, so "build one and
give it to a friend" had no route. the first real case is a plugin that must
never be listed: fofos/fofos-admin is one website's admin panel, useful to
exactly one person.
MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the
URL, refused to overwrite an existing directory, checked for a manifest and
removed a failed clone. exactly one line tied it to the marketplace — where the
string came from. so this is a source parameter threaded through, and a route.
THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is
never declared in a manifest — the id cannot disagree with where the code sits.
that decision is inherited here rather than remade: the repo name becomes the
directory, the route segment, the table prefix and the sidecar suffix. so it is
checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone
under an unusable name reads as a broken plugin rather than an absent one.
the URL is validated identically whether it came from the catalogue or from a
person, because the catalogue is not a trust boundary — it is just where a string
came from, and the same string reaches the same argv. what differs is who may
ask: this route is owner-only, and the owner is choosing what code runs rather
than choosing from a list somebody curated. validation is not review, and nothing
here reads what it cloned.
caught by testing: the source reached installPlugin and stopped there — the
fetchPlugin call still had the old two arguments, so a URL install fell back to
the catalogue and failed with "no catalogue entry". the guard tests are what
surfaced it, because the error named the wrong cause.
verified end to end against fofos-admin — cloned, schema applied, mounted, dock
pinned — then purged, since officer-dev is not that website's machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
the support case this exists for is "it does not work for them and it does for
me". reproducing that with a password reset means locking somebody out of their
own account in order to look at it, which is worse than the bug.
THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and
reaches every handler, because user-middleware puts the whole payload on the
context. without it an impersonated action is indistinguishable from that
person's own — in a log, in a transcript, in anything keyed on a user id — and a
support tool that can forge activity as somebody else is a different and much
worse thing than a support tool.
ONE HOUR, not thirty days. a session lasts a month because a person uses their
own account daily; impersonation is a task you finish. an hour is long enough to
reproduce a problem and short enough that a forgotten tab stops being a live
credential for somebody else's account by lunchtime.
IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any
token carrying `impersonatedBy`, so impersonating another Super Admin does NOT
hand user management back — which would have made this a way to launder owner
actions through somebody else's id. stated twice, in the gate and in the handler,
because one file away is too far for this one.
THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an
owner session — a far more dangerous thing to own than this whole feature. the
cost is that clearing browser storage strands you as the other person until the
hour expires, and signing in again fixes that.
the banner is loud and sits ABOVE the header, outside everything a fullscreen
panel can hide. the failure it prevents is not a hole, it is forgetting: reading
a mailbox or deleting something while believing it is yours. it asks the server
who it is rather than decoding the token, so it cannot keep showing a name after
the hour has run out.
verified end to end against a throwaway account, since this host has one user:
impersonate 200 with the target's id and impersonatedBy naming the owner, 60
minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining
403, user management while impersonating 403; one [SECURITY] line per start. the
probe account was deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.
every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.
THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.
it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.
caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.
verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
three complaints, one root cause and two small ones.
THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message`
and this build answers that with `[]` — 200, well-formed, and wrong. the same
session returns 4 messages on `/api/session/<id>/message`. an empty transcript is
indistinguishable from a new conversation, so reopening a chat said "send a
message to start" and every message the owner had sent looked deleted.
that also explains the third complaint. the model was read from the assistant
turns, so no messages meant no model, so the detail reported a bare `opencode`,
which the composer cannot match — and it fell back to claude code and sonnet. it
now also reads the model off the session record, which is populated before any
assistant turn lands.
the message shape moved with the endpoint: `type` where it was `info.role`,
`content` where it was `parts`, and a user message's text sits on the message
rather than in a part. both shapes are accepted rather than pinned to today's
build — this reader is the only thing between a working transcript and a chat
that looks erased, and a host on a different opencode should lose features, never
its history.
AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing
downstream re-sorted, so the answer sat above the question that produced it.
sorted here, because this is the only place that still has timestamps.
NAMING: opencode is meant to title a session from its contents and does not, on
this surface — sessions stay at `New session - <timestamp>` through a finished
turn and past it, which is why the list was a column of "(untitled)".
`isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI.
so the sidecar names a NEW session from the first line of its first prompt, which
is what the claude side shows. a resumed session keeps what it has, and if
opencode ever does title one it writes after us and wins.
verified live on the session that prompted this: 4 messages, in order, model
opencode/kimi-k3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
an opencode agent could not look at a calendar all afternoon. i treated it as a
framing problem, then a regression, then a free-vs-paid model problem, and it was
none of those. the documentation says all four, plainly.
1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match
^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a
human title there — "Calendar and contacts" — so every one was LISTED and none
could be LOADED. the agent found the skill, called
skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title`
is now a separate field and keeps the prose.
2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config
example and not in the docs, and the difference is exactly this: skills
configured that way are offered to the agent and cannot be opened. skills load
from fixed discovery paths only, so each directory is now SYMLINKED into
~/.config/opencode/skills. links, not copies — one source of truth, and a skill
edited in its plugin is live immediately.
3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed
without complaint and never applied. this is what made it look like a model
problem: the agent existed, the prompt was on its record, and the model had
never seen a word of it.
4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to
ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}.
also: the platform cached opencode's model list for the lifetime of `officer`,
so signing into a provider and restarting the SIDECAR left the old catalogue in
the OTHER process — six free models, no paid ones, nothing saying why. the cache
is dropped when the sidecar reports a new port, which is the one event that
always accompanies a new serve.
verified: the prompt reaches the model — asked who it is, it answered "Officer,
running on your home server at officer-dev under your user account". 10 skills
discovered through the linked path with loadable names. NOT verified end to end:
a turn that opens the calendar skill and answers from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
"can you send a notification to George" had no answer. an agent key reaches its
own holder and nothing else, and the only route that spoke about somebody else
sent to EVERYONE. so the choice was the whole household or nobody.
POST /api/notify/user, ownerOnly, same authority and same reasoning as
/broadcast: everything else under this permission acts on the account asking, and
reaching another person's phone is administration however few phones it is. it
returns the target's email so the caller can see who was reached rather than
trusting an id they typed, and 404s an unknown account rather than letting the
sidecar's silence read as delivery.
AND THE PART THAT MATTERS MORE. /broadcast has answered 404 since the day it
landed. `createSidecarProxy` mounts `router.all('/*')` when it is CREATED, and
hono matches in registration order — so every route added to the proxy's own
router afterwards was forwarded to the sidecar, which has never heard of it.
three days, on a route whose entire purpose was to stop people POSTing to the
sidecar's ephemeral port by hand. a shadowed route does not fail like a missing
one; it fails like a sidecar that has not implemented it, so the 404 reads as
somebody else's problem.
caldav's router learned this on 2026-08-21 and wrote it down. this file now does
the same thing in the same words: the platform's own routes first, the catch-all
proxy mounted LAST.
verified: /broadcast now answers 400 to an empty body where it answered 404
before, /user delivers and reports the address, /_officer/devices still forwards.
found while trying to reach around the notify sidecar's producer secret to do
this by hand — which is exactly what that secret was added to stop, and the
comment above it says so. it stopped me. the route is the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
an opencode agent could not have used the calendar skill written this morning,
and would not have failed usefully either — it had no idea the skill existed and
no key to call the platform with. two gaps, one file each.
SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index
compiled into its system prompt, and `skills.paths` in config taking directories
of <name>/SKILL.md — the same progressive disclosure claude does over the same
layout the platform already stores. so this points it at the SAME FILES rather
than compiling a second index in a second format. skills/load.ts says a skill is
a path because every harness can read a file; this is the better version of that
bet — the harness had the feature all along. verified: `/skill?directory=…` now
returns all nine platform skills, "Calendar and contacts" among them.
THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per
session, so its key is per session and a member's resolves to their own grants.
opencode cannot: ONE serve runs every session, so the only environment there is
is the serve's own, minted once at sidecar start, belonging to the owner.
that is sound today for exactly one reason — opencode is owner-only, enforced at
every door in chat.ts because one serve runs as one user. when that lifts, this
key becomes a privilege escalation. it is the same blocker as the two already
there, not a new one, but it lives in a sidecar where nobody looking at chat.ts
would find it, so chat.ts now names it. all three move together.
verified end to end: a tool process inside an opencode session ran curl with
$OFFICER_AGENT_KEY and got the owner's calendars back.
OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc —
it is inline json, it MERGES into the same chain as the config files (checked in
the binary), and it dies with the process. the alternative is the platform
editing a dotfile it does not own and leaving state behind that outlives the
install.
affordances ride the `instructions` key as one composed file, written beside the
serve's state. loaded — `/config` shows it — but NOT model-verified: this box has
no provider credential in auth.json, so a turn 401s before reaching a model.
whoever adds one should check that first.
the credential module moved out of sidecar/claude/. it was never claude-shaped;
it was just there first, and it had one importer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.
versions are dated and tagged now, so the rule the owner asked for is possible:
· a checkout NOT at a tag is always updateable — it is between releases, no
claim is being made about it, pulling is always reasonable
· a checkout AT a tag is updateable only when a NEWER tag exists
the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.
it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.
the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.
"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.
verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
/marketplace was a list of every plugin on the left and a preview on the right.
that is a fine control panel and a poor shop: twenty-one rows sorted by nothing
anybody cares about, where "something for my calendar" means reading all of them.
now: tags down the left with counts, cards in a grid on the right, and clicking
a card takes over the panel with a back link. three things about the shape.
BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a
filter in component state is lost on Back, so filtering, opening a plugin and
pressing Back would drop you in an unfiltered grid having lost your place.
browser Back works for free for the same reason. selecting a tag also CLEARS the
selection, because a detail for a plugin outside the new filter is a page the
grid you are now looking at cannot return to.
the detail REPLACES the grid rather than sitting beside it. a grid needs the
width and a detail beside one gets half of it; neither reads well. the back link
drops ?selected= and keeps ?tag=, which is the entire point of putting the
filter there.
the panel KEYS are unchanged though both components were replaced. a stored
layout names them, and renaming would drop everyone back to the default
arrangement for nothing — the key identifies a slot, not an implementation.
two things found on the way. PluginItem and AvailablePlugin had no tags, so the
api now carries them on both branches — from the manifest for a plugin that is
here, from the catalogue for one that is not, same field either way. and the
screen used useDashboardState, which persists the whole tree: the panels this
ships would never have reached anyone who had opened it before. useAuthoredLayout
takes the structure from source and persists only sizes, which is what an
authored screen wants.
21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one
rows, and the width is worth more to the grid.
verified: bundle contains the new panels, api returns tags for all 21 with none
missing the field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
opencode discovers AGENTS.md and nothing else — its only target is
H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers
CLAUDE.md. no overlap.
every brief the platform wrote was CLAUDE.md, so under opencode a commander got
none of its standing brief, a bot none of its arming instructions, and a plugin
chat none of its plugin's guidance. nothing failed and nothing was logged: the
agent simply knew nothing, which is indistinguishable from the feature not
existing, and is why it lasted this long. commanders can already run under
opencode, so this was live.
writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a
pointer rather than two copies because these are rewritten every turn, two
copies of that will eventually disagree, and the stale one is the one somebody
reads.
three writers, and the third needed a flag the others must not have:
bots rewritten every turn ON PURPOSE, so a template change reaches them
plugin chats same
commanders onlyIfAbsent — a commander EXTENDS this file over its life, and
rewriting it when the template changed would delete the job it
had agreed with the owner
two things that would have quietly defeated it. the file listing handed to a
commander said "CLAUDE.md — this file", which would have had it editing the
pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a
new commander means burying a credential section inside six lines nobody reads —
it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old
commanders still work.
plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now
chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its
own repository on its own release cadence and dropping the old name would
un-brief any installed copy that had not updated — silently, again.
tested: both files written, pointer is not a second copy, idempotent, rewrites
on change, and onlyIfAbsent leaves an extended brief alone. verified on disk
after a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.
what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.
three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.
two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.
/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.
docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.
verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
the picker above an email composer listed conversations from the owner's home —
a chat started somewhere else entirely, offered as if it belonged to the mailbox.
the address had two resolvers. the chat socket knew `context: 'email'` and ran
the turn in the account's directory; `GET /chat/sessions` did not, and fell
through to `who.home`. so a message went into the mailbox and could not be found
in the list directly above where it was typed. two answers to "where does this
chat live" is one too many, and the one that was missing failed quietly.
`resolveEmailAccountDir` in data-path.ts is now the only answer, and both call
it. it verifies a client-named mailbox against disk rather than sanitising it
into shape, which matters because the socket's caller ends in mkdirSync — an
unrecognised value would not fail, it would CREATE a directory named after
whatever arrived and run turns in it. `all`, the list's own sentinel for the
unified view, is exactly the value most likely to arrive by accident.
pure: no mkdir, no writes. the caller that needs the directory says so itself.
tested, because a missing branch is what this was: the named mailbox wins, `all`
never becomes a directory, a mailbox with no emails.db is not a mailbox, and no
account at all resolves to the accounts root rather than to something invented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
ChatPanelWrapper had one plugin's screen id compiled into a shared platform
package: `workspace.id === 'email'` → `{ context: 'email' }`. its own comment
said the general fix was a way for a plugin to DECLARE its chat context, and
that a second hardcoded id would be the signal it was needed. that fix exists
(`declared`), the email plugin now passes it, so the branch is gone.
it also could not fire any more, which is the part worth keeping. a plugin's
workspace key is `screens/plugin/<name>`, so email's id became `plugin/email`
the day it was extracted and the test silently stopped matching. a hardcoded id
does not fail loudly when the thing it names moves — it just stops being true,
and nothing said so for eight days.
two things on the backend half:
resolveEmailCwd now installs the email plugin's chat/CLAUDE.md. an email chat
runs in the account's directory, not the plugin's chats directory, so the
`plugin` branch never reaches it — the brief follows the chat rather than the
chat moving to the brief. a second plugin wanting this is the signal that
"which directory does this chat run in" belongs in the manifest.
and it verifies the mailbox exists before joining it. `accountEmail` comes from
the client (the mailbox picker's `?account=`) and this function ends in mkdirSync
— so an unrecognised value did not fail, it CREATED a directory named after
whatever arrived and ran the turn in it. the list's unified view uses the
sentinel `all`, which is the value most likely to show up by accident. the panel
drops it; this is the half that does not depend on the panel remembering to.
docs/claude-md-touchpoints.md updated: the copier now has two callers.
unrelated and NOT from this change: ModelSelector's "an unstarted composer gets
a default" fails on master too — verified by stashing these files and rerunning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
the bot came back three times after being deleted, untitled each time, and the
mechanism is worse than an orphan process.
its watch had no record, so deleting it stopped nothing. the watch kept polling,
fired, and the hand-back resumed the session by transcript id — at which point
`claude --resume` RECREATED the transcript that had just been deleted. so the
bot reappeared in the list and did it again on the next fire. same shape
22a9d58d fixed for commanders, arriving from a different direction.
the record could never have covered this: it depends on the bot having written
one, and a bot that did not is precisely the one nobody can stop. a mechanism
that only works when the agent remembered is not a mechanism.
so deleting also sweeps: any process running a .sh inside the bots directory
whose pid appears in NO record is killed. claimed pids are never touched,
whatever else is true — a live bot's watch is the one thing this must not
reach. reads /proc rather than shelling out, since it runs inside the delete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
the automatic title is the owner's first sentence truncated — "can you notifiy
me when https://newweb.fofos…", typo included. that is a prompt, not a name,
and it is what they scan a list of bots for later.
so after arming: two to four words, the subject and what is watched about it.
"newweb uptime", "NYT front page". explicitly NOT "Website Monitor Bot" — every
bot is a monitor and the word earns nothing in a list of them.
this also closes the hand-wave in the record step. it said "your session id is
the file name you were told to use — if you do not know it, look at which
transcript you are in", which is not an instruction anybody can follow. now it
lists /chat/sessions?context=bots and matches on the title, because claude
titles a session from its first message and on the arming turn that entry is
you.
which puts the three steps in a REQUIRED order, stated: find your id, write the
record, then rename. renaming first destroys the only thing that identifies you
in that list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
a bot was deleted tonight and its watcher was still polling a website eleven
minutes later. nothing in the product could find it: a bot's watch is a
detached shell process, killing the harness ends the agent, and the loop it
started is not its child.
so a bot writes <sessionId>.bot.json beside its script — what it watches, the
script, the state file, the pids. that record is the ONLY thing connecting a
process to the bot that owns it, and the brief now says so in those words
rather than as bookkeeping.
the id is in the FILENAME because bots share a directory: deleting knows only
an id, so this is a lookup rather than a scan, and a record whose contents are
damaged still gets found and cleaned up.
DELETE /chat/sessions/:id tears it down after forgetting the tables — children
first then the recorded pid, same ordering as stopCommanderProcesses, so the
loop cannot spawn a replacement between the two. every transcript in the
returned chain, not just the id asked for. unconditional rather than gated on
"was this a bot", because the record only exists in the bots directory and a
branch on cwd is a second place to get it wrong. script and state go too, and
only if they are inside the bots directory — an agent wrote that path.
and the reviver sweeps bots on the same tick. a record with pids and none of
them alive is a stopped watch; there is no armed flag and there should not be,
since the record is written when it arms, so its existence IS the intent. the
three-wake bound is shared, keyed bot-<sessionId>.
the wake prompt forbids reconstructing a watch from memory when the record is
gone: a watch subtly different from the one agreed is worse than none, because
it looks like it is working.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
arming one watch took eight discovery calls and a 307-line read of the
commander skill before anything happened. the brief said "use the background
job you already know" and it did not know it, so it went looking — and what it
found is a document about spawning agents in a loop, almost none of which
applies to a bot.
so the mechanism is written down here instead: write the script in this
directory, run it with the Bash tool and run_in_background, the script waits in
the shell and exits when the condition is true, the exit is the signal, re-arm
before ending the turn. plus the shape almost every bot is a variation of, with
the twice-in-a-row rule already in it.
and it says NOT to read the commander skill, by name. an instruction to look
somewhere is an invitation to read everything on the way.
one real fix rides along: the first bot wrote its script to /tmp, which does
not survive a reboot. the template puts the script and its state file beside
each other in the bots directory, and says to write the new state BEFORE
exiting — otherwise the next arming fires on the same change again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
"notify me when x.com is down" is a complete instruction. the brief now says so,
and lists every question it was likely to ask along with the answer it should
use instead: one minute, twice-in-a-row before calling something down, follow
redirects, ten second timeout, extract the smallest stable piece rather than the
whole page.
two of those are the difference between a bot somebody keeps and one they turn
off. firing on a single failed request produces a watcher nobody trusts. and the
FIRST run establishes the baseline without firing — otherwise arming a headline
watch immediately announces the current headline, which nobody asked about.
and it can be told who to tell in prose. "@Name [id]" carries an id, but "the
todo guy" is how it will actually be said — so: look it up in the chat list,
match on title, use it if exactly one matches and say which. several matches is
worth the one question. no match is worth asking.
the line between defaulting and asking is stated rather than left to taste: no
address at all is a question, a missing interval is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
a bot is a chat whose working directory is bots/, armed through the background
job any chat can already arm. no scheduler was written and none was needed:
handoff already means "poll cheaply, wake the agent only when it fires", which
is the expensive half of a commander without any of its ceremony.
ONE directory, not one per bot. a commander earns its own because it is a job
with procedures and a ledger; a bot is a sentence and a watch, and a directory
each would mean a creation step before anything can be said. sharing it makes
the list free — every bot is a chat in that cwd, so the header's picker is the
list of bots.
it earns NO permission of its own. /bots rides `chat`, because a bot adds no
surface: same routes, same socket, same confinement. the registry says so
rather than leaving the next reader to wonder.
the brief is the feature. it tells the agent to arm on the FIRST message and
not interview — pick a sensible interval, arm, say what you armed in one line,
and let the owner correct it, which is cheaper for them than three questions.
it says the owner will never say "notify" or "nudge" and must not have to. it
forbids the loop that wakes on a timer to look for itself, which is the
expensive mistake available here. and it draws the line: a bot watches and
tells, it does not fix — a request for work is a commander, say so and stop.
bots joins commanders and plugins in confineUserTree, third time: a member's
bot runs as the member and a 700 service-user directory kills the turn on its
own cwd.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
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
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
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
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
$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
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
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
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
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
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
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
"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
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
"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
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
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
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
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
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
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
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
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
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
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
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
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
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
"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
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
"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
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