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:
2026-08-24 02:10:37 +00:00
co-authored by Claude Opus 5
parent 75d5a795a2
commit b6d6de7e53
25 changed files with 870 additions and 674 deletions
+59 -10
View File
@@ -198,17 +198,27 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
/**
* Every commander a user has shares this ONE directory as its cwd — deliberately not one directory per
* commander. The first design was per-commander, and was dropped the same night it was proposed: a fresh
* directory would have to exist before or exactly when a commander's first turn fires, and racing that
* creation against the harness's own spawn is precisely the kind of timing hazard Claude Code's
* directory-per-project convention was never built to be driven by. One shared, pre-existing directory
* removes the race instead of handling it.
* The root every commander's own directory lives under — `$DATA_PATH/<email>/commanders/<dirName>/`.
*
* **Not a shared cwd any more.** Two designs were tried and retracted before this one. First, one directory
* per commander, dropped the night it was proposed: a fresh directory had to exist before or exactly when a
* commander's first turn fired, and racing that creation against the harness's own spawn was the kind of
* timing hazard Claude Code's directory-per-project convention was never built to be driven by. So it became
* one shared directory for every commander a user has — which removed the race but made the directory the
* commander's only identity, which is what made the ordering problem `docs/commander-directory.md` describes
* inescapable. Then, briefly (2026-08-22), a commander chose an arbitrary directory anywhere in its owner's
* tree, once identity moved to the `command_hq_sessions` row instead of the cwd.
*
* **One directory per commander again, and the race is gone for a different reason than either previous
* attempt solved it.** The row (and so `dirName`) is created by `POST /command-hq/commanders` BEFORE any
* turn spawns — the ordering fix is exactly what makes a fresh, empty, commander-named directory safe to
* create ahead of time: nothing races its own creation, because creation happens first and spawning waits
* on it. See `docs/commander-directory.md`.
*
* `DATA_PATH`-rooted even for the OWNER, which is a deliberate departure from ordinary `/chat` — an owner's
* normal chat runs in `getOwnerHomeDir` (their real login home). Commanders use the managed directory
* instead, on both sides of the account split, which is what keeps them out of the owner's own project
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list. See `docs/command-hq.md`.
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list.
*
* **A sibling of `home`, but reachable like `home` — not confined like every other sibling.** Every other
* directory alongside a member's `home` (`attachments`, `email_accounts`, …) is 700 and owned by the
@@ -216,11 +226,51 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
* wrong for a directory a member's own `claude` must `cd` into via `setpriv`. `confineUserTree`
* (`os-user.ts`) chowns this one to the member the same way it chowns `home`. An account provisioned
* BEFORE this existed will not have that chown until it is re-provisioned once — see `docs/command-hq.md`.
*
* That chown is recursive at provisioning time only — a subdirectory created for a NEW commander afterwards
* is written by the platform process (the service user), not by the member, so it will not automatically
* carry the member's ownership the way the root does. Not solved here; flagged in
* `docs/commander-directory.md`'s implementation notes as a gap for a member-owned commander specifically,
* not a regression this file introduces.
*/
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
/**
* Where a commander records what it spawned — one directory per commander, named by its session id.
* One commander's own directory — `CLAUDE.md`, `state/`, and whatever it writes for itself. `dirName` is
* `command_hq_sessions.dir_name`, derived from the row's own serial id at creation. See
* `docs/commander-directory.md`.
*/
/**
* One commander's own directory.
*
* **Guarded, because two callers `rm -rf` what this returns** — deleting a commander removes its directory,
* and `dropSpawnedRecord` removes its `state/`. An empty `dirName` would resolve to the commanders ROOT and
* take every commander the account has with it.
*
* That is not hypothetical arithmetic. `createCommander` inserts the row with `dirName: ''` and fixes it up
* in the same statement pair, because the name is derived from a serial id that does not exist until the
* insert has happened. The transaction means nothing else can observe that state — and a guard here means
* it does not have to, which is the difference between safe and safe-so-far. A traversal is refused for the
* same reason, cheaply, rather than reasoned about.
*/
export const getCommanderDir = (email: string, dirName: string): string => {
if (!dirName || dirName.includes('/') || dirName === '.' || dirName === '..') {
throw new Error(`refusing to resolve a commander directory for ${JSON.stringify(dirName)}`);
}
return join(getCommandersDir(email), dirName);
};
/**
* Where a commander records what it spawned and its loop's own pid — `state/` inside its own directory,
* keyed by `dirName` rather than a transcript id.
*
* Used to be a directory of its own (`commander-state/<transcript id>/`, a sibling of the transcripts
* rather than the commander), because there was nothing else to key it on before a commander had an
* identity independent of its transcript. Collapsed in here 2026-08-24: `state/` is INSTANCE data (what a
* commander has handled, what it spawned, its loop's pid) as opposed to `CLAUDE.md` and whatever else the
* commander writes beside it, which is DEFINITION — the split `docs/commander-directory.md` calls
* structural, kept as a subdirectory rather than a convention precisely so it is enforced by a path rather
* than remembered by whoever writes into it.
*
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
@@ -231,8 +281,7 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
* completely, its children cannot be followed, and its cost cannot be totalled.
*/
export const getCommanderStateDir = (email: string, sessionId: string) =>
join(DATA_PATH, email, 'commander-state', sessionId);
export const getCommanderStateDir = (email: string, dirName: string) => join(getCommanderDir(email, dirName), 'state');
/**
* Which mail account's store to use, found on DISK rather than by asking the database.