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
141 lines
6.8 KiB
TypeScript
141 lines
6.8 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { getUserByEmail } from 'officerdb';
|
|
import { lookupOsUser } from '@@/os-user';
|
|
import { join } from 'node:path';
|
|
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
|
|
import { loopPidPath, spawnedRecordPath } from './spawned';
|
|
|
|
/*
|
|
A commander's own directory, created once, up front — `docs/commander-directory.md`.
|
|
|
|
$DATA_PATH/<email>/commanders/<dirName>/
|
|
CLAUDE.md written here, extended by the commander as the job is agreed
|
|
state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation.
|
|
|
|
Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the
|
|
working directory at session start, so a commander told about itself here needs no per-turn injection —
|
|
see the deleted `commander-block.ts` for what this replaced.
|
|
*/
|
|
|
|
export type CommanderClaudeMdParams = {
|
|
name: string;
|
|
referencePath: string;
|
|
loopPidPath: string;
|
|
spawnedPath: string;
|
|
};
|
|
|
|
/**
|
|
* The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps
|
|
* extending, and the owner may open it directly.
|
|
*
|
|
* Deliberately does NOT carry the button-specific framing `commander-block.ts` used to add on a
|
|
* `selfStarted` turn ("nobody has told you what they want yet — they pressed Start"). That framing is
|
|
* conditional on how THIS conversation happened to begin, which a file written once and read on every
|
|
* future session cannot express — and it would be actively wrong the day a fresh conversation replaces a
|
|
* dead one in this same directory, or if the opening message was typed rather than a button press. The
|
|
* button case is still covered: `CommandHqDetail.tsx`'s `startBrief` composes that framing into the
|
|
* opening message itself, which is the right place for something true only of one specific turn.
|
|
*/
|
|
export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
|
return [
|
|
`# ${params.name}`,
|
|
'',
|
|
'This is a commander: an agent that keeps working when nobody is watching. This file is its own —',
|
|
'extend it as the job gets settled, and again whenever what it watches or what it does about that',
|
|
'changes. Open the **Commanders** skill before starting or changing a watcher; it carries how the',
|
|
'pieces fit, what to establish first, and the mistakes that are expensive.',
|
|
'',
|
|
'## What this watches',
|
|
'',
|
|
`Reference: \`${params.referencePath}\``,
|
|
'',
|
|
'That path is what this commander operates ON, not where it lives — this directory is its own, kept',
|
|
'separate from the thing it watches. Read it before asking what the job is: it was named before',
|
|
'anything else was said, so treat it as the subject rather than decoration. More references can be',
|
|
'added here as they come up in conversation.',
|
|
'',
|
|
'## Recording itself',
|
|
'',
|
|
'Two paths only this commander can be told, and it must write to them or it cannot be stopped:',
|
|
'',
|
|
`- its loop's own pid: \`${params.loopPidPath}\``,
|
|
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\``,
|
|
'',
|
|
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
|
|
'record is the only one. Without it, deleting this commander leaves its loop running.',
|
|
'',
|
|
'## Before arming anything',
|
|
'',
|
|
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',
|
|
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
|
'constantly and is found out days later. Ask, and arm once it is agreed.',
|
|
'',
|
|
'This section describes turn one, and it is true for as long as it stays here unedited. Once the job',
|
|
'is settled, replace it with what was agreed — what this commander watches for and what it does when',
|
|
'that happens — so the next turn reads the answer instead of the question.',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Create a commander's directory and write its `CLAUDE.md`. Called once, by `POST /command-hq/commanders`,
|
|
* before any turn spawns — that ordering is the entire fix `docs/commander-directory.md` describes.
|
|
*
|
|
* `state/` is created empty rather than left to be created lazily on first write (as `getCommanderStateDir`
|
|
* callers already do defensively): creating it here means the directory listing a commander's first turn
|
|
* sees is complete from the start, definition and state both present, rather than state/ appearing later
|
|
* as an unexplained addition.
|
|
*/
|
|
/**
|
|
* Give the new directory to the account that will work in it.
|
|
*
|
|
* `confineUserTree` chowns `commanders/` to the member ONCE, at provisioning. Anything created after that
|
|
* belongs to the service user — so a member's commander directory would be one their own `claude` cannot
|
|
* write into, and the failure surfaces as an agent that dies on its first command in a directory it can
|
|
* see but not use. Exactly the `general_chat_sessions` mistake `resolveChatCwd` already tells the story
|
|
* about, one level deeper.
|
|
*
|
|
* A no-op for the owner, whose account has no `osUser` and who already owns everything under `DATA_PATH`
|
|
* — which is why this was invisible: it works perfectly for the person most likely to test it.
|
|
*
|
|
* Best effort. A commander whose directory could not be handed over still exists and still works for the
|
|
* owner; refusing to create it would be the worse trade. The failure is returned so the caller can say so.
|
|
*/
|
|
async function giveToAccount(email: string, dir: string): Promise<string | null> {
|
|
const user = await getUserByEmail(email).catch(() => null);
|
|
if (!user?.osUser) return null;
|
|
|
|
const ids = await lookupOsUser(user.osUser);
|
|
if (!ids) return `no Linux account named ${user.osUser}`;
|
|
|
|
const proc = Bun.spawn(['sudo', '-n', 'chown', '-R', `${ids.uid}:${ids.gid}`, dir], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
const err = await new Response(proc.stderr).text();
|
|
return (await proc.exited) === 0 ? null : err.trim() || 'chown failed';
|
|
}
|
|
|
|
export async function createCommanderDirectory(
|
|
email: string,
|
|
dirName: string,
|
|
params: { name: string; referencePath: string },
|
|
): Promise<{ dir: string; ownershipError: string | null }> {
|
|
const dir = getCommanderDir(email, dirName);
|
|
mkdirSync(dir, { recursive: true });
|
|
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, 'CLAUDE.md'),
|
|
commanderClaudeMd({
|
|
name: params.name,
|
|
referencePath: params.referencePath,
|
|
loopPidPath: loopPidPath(email, dirName),
|
|
spawnedPath: spawnedRecordPath(email, dirName),
|
|
}),
|
|
);
|
|
|
|
// Last, so it covers CLAUDE.md and state/ as well as the directory itself.
|
|
const ownershipError = await giveToAccount(email, dir);
|
|
return { dir, ownershipError };
|
|
}
|