ab2336516ffef555fd9f23f5b8c6895f7e3ee58d
177
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bf8bfa6859 |
start a commander from a plugin chat, without knowing where anything lives
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
|
||
|
|
02f20d419d |
an opencode chat you can reopen: it has a name, its messages, and its model
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 |
||
|
|
f0801808c4 |
read the documentation: four things wrong, none of them the model
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
|
||
|
|
e6da5d8c77 |
opencode gets the skills and a credential, which it had neither of
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 |
||
|
|
276b12607a |
every brief the platform writes, where both harnesses will find it
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
|
||
|
|
7e6cfc85af |
one resolver for where an email chat lives
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 |
||
|
|
be210c3ed4 |
a plugin declares its chat directory; email stops being hardcoded here
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
|
||
|
|
d58652bf14 |
a deleted bot stays deleted
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
|
||
|
|
84d9c9d2d8 |
deleting a bot stops it, and a dead bot wakes up
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 |
||
|
|
de538cfc1b |
/bots — one sentence arms a watch
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
b6d6de7e53 |
commanders: a commander is a directory, not a session
Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.
The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.
commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.
Four things found in review, none of which the build had.
Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.
getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.
The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.
And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.
Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.
The single existing commander was deleted rather than migrated, per the design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
f6b9ddc877 |
a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists its children; opening one gave no way back — no link to the conversation that owns it, and nothing to tell it apart from a chat somebody had started themselves and forgotten. `parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from the other end. So this needed no table and no write path: a commander's children were recorded because deleting one has to take them with it, and that record answers "who is my parent" for free. Nothing had asked. `GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line above the transcript linking back to the commander. Null for almost every session, which is the normal case rather than a miss — a chat somebody opened has no parent — so the bar renders nothing at all rather than saying so. Never fatal: a session must open when this lookup will not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa35428dde |
usage limits are read live, and a cached reading belongs to one account
`limitsCache` was keyed on nothing. Switching account changes which credential `readOauthLogin` returns, and the cached entry was served anyway — so the panel showed the previous account's percentages under the new account's name for a minute, and on a failed probe for as long as the probe kept failing. Reported maxed out on one account, switched, and the numbers did not move. So the limits have no TTL now: every request probes. They are per-account and they are the number somebody opens the panel to read, so a stale one is worse than a slow one. The store stays, for the failure path only, and keyed on the account it was measured for. A flaky request degrades to that account's own last reading rather than to a blank panel or — much worse — to somebody else's numbers. A reading that cannot be attributed gets no fallback at all rather than a shared one. The client's five-minute stale window went with it: that query is only the instant first paint, and holding it meant painting the old account's stored row until the refresh landed. The transcript scan keeps its cache. It reads ~90MB and its answer is the same whichever account is signed in — it counts what this machine wrote, not what an account is allowed — so a switch does not invalidate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
91bc4ac74f |
the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness cannot write until the first turn ends. So the list showed the opening brief until then, while the header beside it showed the name from local state, and every fix for that was a cache-invalidation trick timed against somebody else's write. Three attempts, all of them fixing the symptom. `command_hq_sessions.name` is written when the commander is filed, before the first turn produces anything, and the list and detail routes prefer it. Nullable, so rows filed before the column fall back to the transcript. The rename still happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it just is not the source of truth any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a51a969cd0 |
a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening of commander testing. They are transcripts containing only harness metadata — last-prompt, ai-title, atis-latch — written after a session was deleted or before it ever sent anything. Filtered at the list, not deleted on disk: the files belong to the harness, and a cleanup racing it would be the same class of bug that produced them. Which is also the cause of two of the four. The delete path resolves officer's session key from two in-memory maps, and both answer only for a session younger than the process. After a restart neither does, so no kill is sent, nothing is awaited, and the transcript is removed while the harness is still writing — which writes it back. Same failure as the events cleanup, same durable answer: the event rows carry both ids, so ask them when the maps cannot say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5c802b07b5 |
a command dropped for want of a sidecar has to say so
`sendFire` looked up a sidecar, and if none was registered it did nothing. No log, no error, no return value — the command simply stopped existing. That window is real and entirely predictable: every `pm2 restart officer` has one while the sidecars reconnect, and somebody clicking a button has no idea they are in it. The cost landed hardest on answering a question. `answerClaudeQuestion` in the agent documents this exact failure in a careful comment and cannot report it, because the command never arrives to be reported. So the question card kept spinning, which from the person's side is indistinguishable from a slow agent, and the only move left is to send another message — the worst possible thing to do to a waiting tool call. Observed tonight, on a restart I ran myself while a commander was mid-question. So `sendFire` returns whether anyone took the command and warns when nobody did, and the two paths where a person is actively waiting — a question answer and a plan decision — tell them instead of leaving the card to spin. The other five callers are unchanged and now leave a log line, which is the part that was missing when this was diagnosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2aef23c8ba |
a commander starts its own conversation, instead of being handed one
Starting a commander meant writing a good opening prompt, which is the hard part of the whole thing — so in practice somebody else wrote it and pasted it in. That is not a flow, it is a person acting as the intake form. Now the two decisions that cannot be guessed stay with the owner (a name and a working directory) and the rest is a button. It sends a brief the MODEL reads — "I have not told you what I want yet, orient yourself and ask me one question" — while the transcript shows "Set me up.", which is what a person would have typed. That split is `effectivePrompt` vs `displayText` and the chat path already had it. What to ask, in what order, and to read the directory BEFORE asking anything, all live in the Commanders skill under "When you started yourself". Deliberately not here: capabilities are re-read from disk every run, so rewording a question costs nothing, while the same words in commander-block.ts cost a restart each time. The block carries a pointer and no doctrine. Orienting first is what makes this work on a real request. Told "automate the platform deploys", a commander that has read the directory finds `deploy/`, the previous releases and the deploy skill, and asks whether that is still how you want it — instead of inventing a scheme next to the one already there. `sendPrompt` gained an options object rather than a tenth positional parameter. Nine is already past where a call site reads as a row of undefineds. The composer stays enabled beside the button: typing your own opener still works, it just is not the only way in any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eefcb06e35 |
stop the harness before deleting its transcript, and wait for it to actually stop
Deleting a commander removed its transcript and the `claude` CLI wrote it straight back. Verified rather than inferred: the file returned with 22 lines, every one timestamped after the delete, and the deleted commander was sitting in /chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered it out. Delete a commander, get an untitled chat back. Two causes, and the first one hid the second. The kill never happened. `killClaudeSession` was imported straight from `claude-manager` into a route that runs in `officer`, while the session map lives in `officer-claude-code`. It read an empty map in the wrong process and returned false every time. `claude:kill` and `killClaude` already existed for this; the route just wasn't using them. And killing is not being dead. `abort()` returns while the CLI is still unwinding, and officer does not own that file — the CLI does. So a session now carries an `exited` promise resolved by the same exit hook that already reaps it, `claude:kill` replies only once that resolves, and both delete routes await the reply before touching the filesystem. Bounded at 5s and never fatal. A child that will not die delays a delete by seconds instead of hanging it, and a sidecar that is down leaves the caller with exactly the race it already had. `markExited` fires before `dropDeadSession`'s identity guard, deliberately: a waiter has to be released even when a newer session has taken its key, or it blocks for the full timeout on a process that died on schedule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ac3ca7fe5 |
forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same conversation, and `forgetChatSessions` took one list and passed it to all four — so the events delete matched nothing. Not sometimes: by construction, every time. A deleted conversation left its entire durable output behind. It survived two verifications because counting events BY TRANSCRIPT ID also returns zero by construction, and zero read as "cleaned". What settled it was reading an orphaned event instead of counting it — the payload carries both ids side by side and says outright which key the row is filed under. So the parameter is now named for its id space. That is the whole defence and it is the only one available: nothing else distinguishes the two, since both are uuids of the same shape, and a positional `string[]` cannot be got wrong loudly. Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p` outside officer's websocket never had a key and never wrote an event, which is exactly a commander's children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3837af657d |
live sessions default to agents only, and you ask by name for a commander
`?kind=` was opt-in, so a client that said nothing got everything — which is the wrong default for the one caller that cannot cope. The phone swipes between live chats one at a time, has no room for tabs and no way to skip a row, so a commander in that list is a commander you are stuck on. Defaulting to `agent` means forgetting the parameter can only ever under-report. The two callers that want both now say `kind=all` explicitly, and that is a fair price: they are few, they are on screens built to show both kinds, and they are the ones positioned to notice when the answer is wrong. `counts` still describes every live session whatever the filter, so a filtered caller renders both tab numbers from one request rather than two. Also corrects useLiveSessions' comment, which said `isCommander` was derived from the cwd. It stopped being true when a commander gained the right to choose its own working directory — it comes from the `command_hq_sessions` row, and the directory answers nothing. Unrelated but caught by docs:check on the way past: the registry gained a nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which also left the generated platform skill two lines stale. Both corrected; the check is green again. The minesweeper feature itself is half-landed and not mine — `rfb.ts` fails typecheck and its registry test fails on a websocket that is not served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
749c80c877 |
the server says which live sessions are commanders, and can return just one kind
/chat/live already classified every row from `command_hq_sessions`; what it did not do was filter, so each client repeated that itself. Fine for the web list, which shows both tabs at once. Wrong for the iPhone app, which swipes between live chats, has no room for tabs, and must simply never be handed a commander. `?kind=agent|commander` does it server-side. Counts always describe the whole set regardless of the filter — the web tabs show both numbers side by side and both must be right whichever tab is open, so a filter that also hid the other count would force a second request to render one screen. An unrecognised kind is a 400 rather than "everything": a mistyped `?kind=agents` silently returning commanders is the precise swipe-order bug this exists to prevent, so the plural forms are accepted too, since the client's own type is already plural. And /chat/sessions no longer tags rows it is about to delete. It called listCommandHqSessionIds twice — once to mark commanders, once to remove exactly those rows four lines later — so isCommander was false on every row that survived. A field that could only ever lie, plus a second identical query per request to produce it. A list that excludes commanders has nothing to say about which of its rows is one; withCommanderFlag had no other caller and is gone. Not restarted: the shared checkout currently fails typecheck on another agent's untracked Minesweeper work, and a restart is when half-applied work ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ae27478a27 |
the platform keeps its own transcript-id mapping, because the sidecar's is invisible to it
The commander block still was not arriving. Not the delivery mechanism this time — the LOOKUP. Building the block needs the transcript id, the platform had only officer's session key, and it asked the sidecar's `state.ts` for the mapping. That map lives in the sidecar's memory; the platform process has its own empty copy of the module, and re-reading the state file from here does not find a live session either. Verified both ways before changing anything. So the platform keeps its own map, filled from the `session:claude` message it already handles — the one moment the mapping is announced. It is not derivable from anything: it is stated once, mid-turn, and whoever needs it either records it as it passes or does without. The delete route had the identical problem for the same reason and now asks the platform's map first, keeping the sidecar lookup as a fallback for a session that predates this process. Third distinct bug today from these two ids, and they are all the same shape: a lookup keyed on one id space against data held in the other, failing silently because both are UUIDs and neither side can tell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aad382f4be |
a commander is told where to record itself on a TURN, because the preamble never reaches it
Its own first test caught this: the commander armed a perfectly good watcher, established a baseline correctly, and wrote no loop-pid — so deleting it would have left the loop running. The block was composed into the system preamble, which `createSession` builds ONCE, on a conversation's first turn. For a commander that is exactly the turn where the block cannot exist: no command_hq_sessions row, no transcript id, so `commanderBlock` returns null. Every later turn reuses the session and never rebuilds the preamble. So a commander never received it at all — the "first turn gets no block" limitation I closed with a rule was really "no turn ever gets it". It rides `effectivePrompt` now: the string the model sees, as against `displayText` which the transcript shows. Same seam the background-job dialog already uses for its hidden preamble, so nothing new — the agent gets the paths without them appearing in the conversation. Once per session, on the first turn where the block resolves, and it stays in the conversation's history from then on. `briefedSessions` is in memory, so a restart re-briefs. That is the safe direction: a few hundred tokens on one turn against a commander that was never told and therefore cannot be stopped. The preamble path is removed rather than left dormant — a dead branch that reads as the mechanism is how this went unnoticed in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fc8ce04f2b |
the commander's name shows the moment it is typed, not after a refresh
Naming works — confirmed on a live commander, which now carries `{"type":"summary","summary":"Inbox
Summarizer"}` in its transcript. The instrumentation showed exactly the predicted shape: two attempts 2.8
seconds apart, the first on `session:claude` before the transcript file existed and the second on `result`,
which is the one that landed. The retry was the fix; the guess about WHY was right and the evidence was worth
having rather than assuming.
What was left was cosmetic and looked like the same bug: the pane still read "Untitled commander" until a
refresh. It could not have read anything else — the rename lands at the end of the first turn, and this bar
has no fetched title because a brand-new commander has nothing to resume. So it showed the one thing on
screen nobody had chosen, while the chosen name sat in a field two inches above it.
It takes the name from state now, which is where it already was.
Instrumentation removed. It earned its place: three attempts at this bug were reasoned and two of them were
wrong, and the log settled it in one run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cb51f35cad |
deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in chat_session_events, against 14 live transcripts. Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and the delete path has to be explicit. It was not. Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted conversation is a timer that keeps firing at something that no longer exists. That is what turned this from untidy into worth doing now. deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug at a smaller scale. THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))` and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now. Fifth silently-wrong thing today that a typecheck could not see. Cleared the existing orphans with the same function the delete path now uses, after checking the one thing that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home this scan does not read. There is one account on this machine, so they were all the owner's. Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written independently of the transcript — that is what makes turn output survive officer being down — so a session that has just begun legitimately has events before a transcript exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2bd8701b15 |
a commander runs where the owner chose, and Command HQ stops scanning one folder
Two of the three things the identity change unblocked. THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely — every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)` containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one falls back to the account's home, which is the honest last resort rather than a silent special case. THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones that happen to still live there and silently hide every other. It reads its rows and loads each transcript by id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row whose transcript is nowhere is treated as absent exactly as a missing file was. `POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different directory, from a housekeeping route, when the rows are what identity now depends on. Three smaller corrections that came with it: - rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually wrong reads as intent later. They pass `who.home` now. - `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given. - the router's "Where commanders live" section said every commander shares one directory and that listing is therefore a scan. Both sentences were the design being replaced. `loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is the price of letting each choose where it lives. Verified on live data: the existing commander still resolves, with its title and its real updatedAt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d08ae41f1f |
a commander can be named before it says anything
Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so the platform renames the transcript the moment it exists. The owner names the commander up front, beside choosing its working directory, and never sees the rename happen. It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`, not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That property already existed and was documented; nothing needed adding to make it hold. Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by the client, because it must not depend on a browser still being open: the name is chosen before the first message, and losing it to a closed tab would leave a commander called after whatever it happened to say first. `chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a member uses it. The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is: a brand-new conversation has no live session to configure and no stored row to update, so its first message is the first moment anything can be acted on. UI comes with the directory picker — both are chosen in the same place, and neither is optional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
75affec091 |
a commander is identified by its row, not by where it lives
Groundwork for choosing a commander's working directory. Identity was the CWD — every commander shared one directory, so the directory was the answer and it needed no query. The moment a commander can choose its own, that test is wrong in BOTH directions: a commander in the platform checkout reads as an ordinary chat, and an ordinary chat in the old shared directory reads as a commander. So identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free. The isolation the shared directory provided is no longer carrying anything — the surfaces that needed it now filter explicitly, which is what made this possible. Four surfaces moved, and the shape of the change is the same in each: the transcript scanner stops inferring it and the ROUTE decorates from the rows. - `listClaudeSessions` says nothing about commanders any more. It is synchronous over a directory scan and identity now needs a database read, so `withCommanderFlag` applies it at the caller. - the sessions list, `/chat/live` and `GET /chat/sessions/:id` each read `listCommandHqSessionIds` once per request. A set membership test per row rather than a lookup per row: commanders are few, and the alternative turns one list into fifty queries. - `commanderBlock` resolves the row instead of parsing the account out of the path. It needs the email for the record paths, so that is a second read — once per session spawn, beside the affordances and the skills index, which are read there for the same reason. `commanderBlock` had to be hoisted out of `createSession`, which is synchronous, into `spawnClaudeStreaming`, which is async and already holds the account. Passing it in as a parameter rather than making the whole session constructor async. `isCommandersDir` survives but is documented as no longer being the identity test — it remains only for the places that legitimately care about that specific path: creating it for a new OS account, and as the historical home of commanders created before this. A commander mid-first-turn has no row and so reports false for one turn. That is the same turn it must not arm anything on, and it corrects itself the moment it is filed. Verified against real data: a filed commander renders the block, an unknown id and a null id both return null, and the live gate still answers 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c2ff88c88 |
a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat rather than navigating to Command HQ — right, and it cost the composer its commander controls, because `isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander, rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a watcher wants, and the one the dialog makes default inside a commander. GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation. The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed. The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only would have broken the brand-new composer, which is where the whole thing starts. Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and isCommander=true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ebfa09e067 |
live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it is a list where the thing you are looking for is outnumbered. Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it, which hides at zero because a standing `0` there says nothing. These two say a lot at zero: `Commanders (0)` is how you find out the watcher is not running, which is usually why you looked. The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander shares one directory, so the directory IS the answer, it costs no query, and it is already right during a commander's first turn, before any row exists. It has to be in the payload rather than computed in the panel, and that is the point of the change: the iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name them. A deploy commander appearing in that swipe order is precisely what must not happen. A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to disarm it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af78f0ed51 |
sessions carry isCommander, so the phone can filter what tabs would hide
A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the two: commanders appeared in the chat list looking like conversations somebody started and forgot. The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew about would fix one surface and leave the other wrong — which is the shape of half the bugs found tonight. Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already reads each session's cwd. So it costs one string comparison, adds no query, and still works for a commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript rather than from code and records whatever directory the process was actually in. Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists, no transcript has that cwd, and Command HQ shows 0 commanders. Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6db26ae439 |
nudger: "not generating" was permission to start a SECOND agent
it fired once, in anger, and put a second claude with bypassed permissions
into 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. it also found a real bug while it was there, which is not a
defence of any of this.
the guard was exactly backwards. it asked one question — "is this session
generating" — and treated NO as permission. but a session the sidecar no
longer holds is also not generating, and `startClaudeTurn` given one of those
does not resume it, it starts a NEW one. so the check that was meant to
prevent interference is the thing that guaranteed a second agent, and the
likeliest state for an overnight target — garbage collected after 30 idle
minutes — is precisely the dangerous one.
three questions now, and only one answer is permission:
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.
any other live session refuse, keep the schedule. two agents with bypassed
permissions in one tree is not something to arrange
on purpose.
the third is blunter than it needs to be — the sidecar does not report a
session's directory, so it refuses on ANY other live session rather than on a
comparison it cannot make. that will decline some safe nudges. given what the
precise version cost, the blunt one is the right trade until the sidecar can
say where a session is running.
|
||
|
|
12c5e51ce4 |
nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a turn, and at 3am nobody types the next message. this is the schedule that does. the door already existed. `startClaudeTurn` takes `ws: null` — extracted that way so command-hq could drive a turn over http — so this is a second caller of an existing path rather than a new way into a live session. i had said the platform lacked that; it did not. the rule that decides the design: never speak while the agent is working. so `every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the session is generating, the row is left alone and reconsidered next tick, which means it fires as soon as the turn ends rather than sleeping through to the next hour. a strict alarm would skip an agent that finished four minutes late and leave it idle for the other fifty-six. `isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be mid-turn, and interrupting one is the failure this exists to avoid. matched on both sessionKey and claudeSessionId: officer's handle and the transcript's filename are different ids, only the agent holds the mapping, and a schedule stored against one would silently never match a live row keyed by the other. one nudge per (user, session) — a second schedule for one conversation is two alarms for one sleeper. counted even when the turn fails to start, so a refusal cannot become a tight loop. disables itself at the ceiling rather than leaving a spent row armed. the tick starts in server.tsx rather than on first use, because a schedule armed before a restart has to survive one. |
||
|
|
46d621c91b |
chat notifications reach the phone, on the same rule as the bell
the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.
deliberately a second subscriber rather than a branch inside notify-delivery.
the two channels share only the question "does this account want to hear about
this" — the browser needs an open tab and sends the chat's real title over a
loopback socket on the owner's own tailnet, the phone needs no tab and must
send NO title, because that hop goes through apple. one function doing both
means an `if (isPush)` at every step, and the first thing to go wrong is a
title leaking because the branch was missed in one place.
two codes, officer/core:chat.turn-ended and chat.input-needed. the route is
written out with its scheme rather than derived from the target: the hub has
no chat screen — chat is Butler, a sibling app — so these deep-link OUT over
officer-chat://, and two of the suite's twenty-one schemes already break the
officer-<app> pattern (officer-videos, officer-readaloud). deriving that works
until it silently does not.
`thread` interpolates now, so `chat:{id}` groups per conversation. a static
thread forces a choice between "three pings from one chat collapse" and "two
busy chats stay apart", and the wrong answer is that every chat is the same
chat.
dropped officer/core:agent: chat.turn-ended supersedes it and its route
pointed at officer://chat/{id}, a hub screen that does not exist.
the banner cannot name which conversation. a chat's title is content and
content does not travel; it says what happened, and tapping opens the chat,
which is where the name lives.
|
||
|
|
72d327e360 |
bell: scope the preference to a user, and make every_turn mean every turn
review of
|
||
|
|
5cba1b65a6 |
notify: only "the app is gone" deletes a device row
reported from the field on the first real push: it failed with BadDeviceToken and the server deleted the registration. the build was production-signed and the row said sandbox — a configuration mismatch, with the device and the app both perfectly fine. pruning there destroys the evidence. the phone re-registers on next launch, the row comes back looking healthy, and the next send fails identically; you can lose a day to that loop without ever seeing what is actually wrong. so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune — apple and google are saying the app is uninstalled. BadDeviceToken, DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check. everything else is transient and still counts toward three strikes. INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed MESSAGE — including a non-string in `data`, which is the mistake this codebase is most likely to make and already has a test for. deleting on it meant one bug in the payload builder would wipe every android device from the registry on a single send, presenting as "android push stopped working" with nothing left to explain why. misconfigured is also not counted as a strike. not deleting is not enough on its own — a persistent mismatch would reach three failures and delete the row anyway, which is the same bug with a slower fuse. the apns log now names the mismatch rather than the reason code, since the code alone sends people to look at the device: it prints the row's environment and topic and says to check the SHIPPED artefact rather than the intermediate build, which is exactly where this one hid. |
||
|
|
44a3d5f655 |
fix(chat): give AskUserQuestion a real answer channel
The harness answered every question itself, instantly, with "The user did not answer the questions." — before the card had painted, let alone been read. The agent then reasoned about a refusal that never happened: on 2026-08-19 it asked the same question three times in a row while the answers sat on the screen. The web UI already worked around it by rendering the card ungated and sending the click back as an ordinary chat message. That got an answer through, but the agent read it as a new instruction arriving from nowhere rather than as the reply to what it asked, and nothing could distinguish a live question from one answered last week — every replayed transcript rendered its old questions as clickable. A PreToolUse hook is the fix, and it has to be a hook rather than canUseTool: under bypassPermissions — which is every session here — the SDK shadows the callback and never calls it. Measured against 0.2.59: a bypassing session's AskUserQuestion reached the hook and never reached canUseTool. Hooks run regardless of permission mode. The hook parks on a promise keyed by the harness's own tool_use_id, and returns `deny` with the human's answer as the reason — which the harness feeds back as the tool result verbatim. "Deny" reads wrong and is right: what it denies is the tool's ability to answer for the user, and what replaces its output is what the user actually said. Held open for 150s in testing with the turn completing normally, so a person has time to think. Keying on tool_use_id rather than an invented id means no new durable event: the questions are already in the session log as an ordinary tool:start, and the answer arrives as its tool:result. One source of truth, replay included — which is what re-enables gating the card on `output`, and with it the staleness fix. The two tests that pinned the old ungated behaviour asserted the workaround as the contract; they now pin the reversal, including a pre-fix transcript reading as settled, which it is. Unanswered questions settle like plans do: 20 minutes, every session-ending path draining them first, and wording that tells the agent the question is still on the user's screen and must not be asked again. Mobile (Butler) has the same workaround and is not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |