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
This commit is contained in:
@@ -634,18 +634,18 @@ async function createSession(
|
||||
// written an hour ago should be offered on the next session.
|
||||
const skills = await skillsBlock();
|
||||
/*
|
||||
And, for a commander, the two paths only this session can be told.
|
||||
No commander block here, and none in `startClaudeTurn` either, as of `docs/commander-directory.md`.
|
||||
|
||||
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
|
||||
preamble, which is where this used to live and which an agent arming itself never sees. See
|
||||
`api/command-hq/commander-block.ts`.
|
||||
*/
|
||||
/*
|
||||
No commander block here, deliberately.
|
||||
Two things used to live here in turn, both dead ends. First, the background-job dialog's own preamble,
|
||||
which an agent arming itself from the conversation never saw. Then a per-turn block riding
|
||||
`effectivePrompt` (`commander-block.ts`, deleted 2026-08-24) — which could not resolve on a commander's
|
||||
FIRST turn either, since this function runs once per session and that turn has no row and no transcript
|
||||
id yet, so the block was null and no later turn ever rebuilt the preamble.
|
||||
|
||||
It used to be composed in, and could never arrive: this runs once per session, and a commander's first
|
||||
turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the
|
||||
preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts.
|
||||
`POST /command-hq/commanders` creates the row, its directory and its `CLAUDE.md` BEFORE any turn spawns
|
||||
now, so a commander is told its name, its reference path and the two paths it must record itself at by a
|
||||
FILE Claude Code reads from its own working directory at session start — the same mechanism this
|
||||
function's `affordances`/`skills` blocks still use for everything else, minus the per-turn cost.
|
||||
*/
|
||||
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user