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'; import { writeCommanderCredential } from './credential'; /* A commander's own directory, created once, up front — `docs/commander-directory.md`. $DATA_PATH//commanders// 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; /** Absolute path to this commander's `officer.env`, or null when minting it failed. */ credentialPath: string | null; }; /** * The section telling a commander it has a credential, where it is, and what it must not do with it. * * Exported because it is written in two places: at creation, and by * `scripts/backfill-commander-credentials.ts` for a commander that predates credentials existing. That * backfill APPENDS it to a `CLAUDE.md` its commander has been extending for days, so this has to be a * self-contained section rather than a slice of one file's layout. */ export function credentialSection(credentialPath: string | null): string[] { if (!credentialPath) { return [ '## Reaching the platform', '', 'This commander has NO credential — minting one failed. It cannot reach the platform API, and it', 'must not work around that by calling a sidecar directly. Tell the owner.', '', ]; } return [ '## Reaching the platform', '', 'This commander has its own credential, and it is the ONLY way it should reach anything:', '', '```', `set -a; . ${credentialPath}; set +a`, 'curl -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\', ' "$OFFICER_API_URL/api/..."', '```', '', 'Use the platform, never the thing behind it. A sidecar — whisper, a media server, anything a plugin', 'wraps — usually also listens on localhost with no authentication, so calling it directly works and', 'looks correct. It is still wrong: it skips every permission, every setting the owner chose and every', 'log, and it breaks silently the day that service moves.', '', 'Prefer a TASK over a raw call where one exists. `GET /api/tasks` lists them and', '`POST /api/tasks//run` runs one and returns what it did — one reviewed definition on disk', 'instead of a re-implementation living in a loop nobody reads.', '', 'It reaches everything the platform does EXCEPT administering it — installing plugins, managing users', 'or roles, changing server settings. That holds for features added later too: the rule is resolved per', 'request rather than frozen into a list, so a plugin installed next year is already covered without', 'this commander being re-issued.', '', 'A 403 is an answer, not a problem to route around. It means this commander was not given that, and', 'the owner is the one who decides otherwise. Say so rather than finding another way in.', '', ...notifySection(), ]; } /** * How a commander tells the owner something, and what it may say. * * Its own function because it lands in two places at different times: inside `credentialSection` for a * new commander, and appended on its own by `scripts/backfill-commander-credentials.ts` to a commander * that already has a credential but predates this. The Voice Memos commander is exactly that case — it * reached for the harness's own push tool, which is a different channel and refuses to interrupt a live * terminal, because nothing had told it Officer has a route of its own. */ export function notifySection(): string[] { return [ '### Telling the owner something', '', 'A push to their devices, in your own words.', '', '`title` is YOUR NAME and nothing else — the platform renders it as `Commander: `, so never', "write the prefix yourself. `body` is the sentence. `id` is this directory's name, which groups your", 'notifications together and is what the deeplink opens.', '', '```', 'curl -X POST "$OFFICER_API_URL/api/notify/_officer/notify" \\', ' -H "Authorization: Bearer $OFFICER_API_KEY" -H "Host: $OFFICER_API_HOST" \\', ' -H "content-type: application/json" \\', ' -d \'{"code":"officer/core:commander","id":"1","title":"Voice Memos","body":"3 memos filed"}\'', '```', '', 'That arrives as **Commander: Voice Memos** / *3 memos filed*.', '', 'Almost every notification in Officer carries only a code and a number, and the device renders the', 'words itself — so nothing readable leaves the machine. This one is the exception: `title` and `body`', 'are yours to write, and they go through Apple and Google to reach the lock screen.', '', '**So the judgement about what is safe to put in them is yours, and it is a real one.** A count, a', 'name you chose, what you did — fine. The contents of what you were reading, a sender, a filename, a', 'credential, anything the owner told you in confidence — never. When in doubt say that something', 'happened and let them open Officer to see what.', '', 'Send few. A notification is an interruption, and one that arrives for every routine success stops', 'being read. Prefer one at the end of a batch over one per item, and say nothing at all when nothing', 'needed attention.', '', ]; } /** * 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.', '', ...credentialSection(params.credentialPath), '## The shape: one trigger, one action, one ledger', '', 'Settled with the owner and true of every commander, not just this one. Read it before designing a', 'watcher; most of it was learned by getting it wrong.', '', '**One trigger.** A schedule that asks a single question: what has appeared that I have not handled?', 'The trigger runs constantly, so it must never need judgement. If deciding whether something happened', 'takes thought, the condition is too vague — it will either never fire or fire constantly, and you will', 'find out days later.', '', '**One action, and it takes ONE item all the way through.** Not a stage at a time across everything.', 'Transcribe this file, classify it, dispatch it, done — then the next file. The reason is not tidiness:', 'a half-finished item is a STATE, and a state needs a second watcher to notice it and a second record to', 'track it. Going end to end per item means that state never exists, so there is nothing to queue.', '', 'The cost is real and worth knowing. If the last step fails, the earlier ones are not recorded and get', 'redone. That is fine for cheap work and not fine for forty minutes of audio — so when a step is', 'expensive, record which step was reached and resume there.', '', '**One ledger, appended after the work.** One line per item, written when it is finished, never before.', 'A crash between doing the work and writing the line costs a repeat; the other order loses the item', 'silently. Append, never rewrite — two writers cannot corrupt an append.', '', 'Key it on a HASH of the source, not a name. Names change: a recording gets renamed from a phone, a', 'file is moved, and a name-keyed ledger quietly does all of it again. Do not key it on the OUTPUT', 'either — outputs move, get filed, get renamed. The source is the only thing that holds still.', '', 'Two consequences to accept deliberately. The same content under two names is one item, done once. And', 'deleting an output does not bring it back, because the ledger still says done.', '', '**Never write into what you watch.** The reference path holds its subject and nothing of yours. Your', 'outputs, records and logs live here, in your own directory.', '', '**A spawned agent follows a FILE, not a prompt.** Put the rules in `procedures/.md` here and', 'point the agent at it. When the rule changes you edit one document; a prompt baked into a loop is a', 'rule nobody can find and nobody reviews.', '', '**Say what is provisional.** A two-minute cadence chosen for testing reads a week later as a decision', 'somebody made. Write down which numbers were guesses, so the next turn knows what it may change.', '', '**Leave the ambiguous alone and name it.** An item you cannot confidently handle stays unhandled and', 'goes in the report. Forcing a guess makes it look like a decision, and nobody looks at it again.', '', '## Where things go', '', 'One layout for every commander, whatever it handles:', '', '```', 'classification/ what this commander has produced, filed', ' / one directory per category, named by you as they are agreed', ' unclassified/ handled and recorded, placement still open', 'procedures/ what a spawned agent follows, one file per job', 'reports/ per-run output — what happened, what was skipped, what needs a person', 'state/ the loop pid and spawned session ids. The platform reads these.', '```', '', '`classification/` is named for the SHAPE, not the payload. Transcripts, extracted invoices, photos —', 'they are all just items being filed, and a commander that calls it `transcripts/` has baked one job', 'into its own directory layout.', '', "**A file's directory is its state.** In a category, it is filed. In `unclassified/`, it is finished", 'work waiting on a person — not a failure, and nothing should re-run the action on it. The ledger says', 'whether an item was handled; the directory says where it belongs. They answer different questions and', 'you need both.', '', '`unclassified/` always exists, even when empty. A commander that creates it only when it first needs', 'one gives you nowhere to look before that, and no hint that looking is a thing you do.', '', '## 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 { 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; userId: number }, ): Promise<{ dir: string; ownershipError: string | null; credentialError: string | null }> { const dir = getCommanderDir(email, dirName); mkdirSync(dir, { recursive: true }); mkdirSync(getCommanderStateDir(email, dirName), { recursive: true }); // Before CLAUDE.md, because CLAUDE.md tells the commander where its credential is and has to say // truthfully whether there is one. Best effort for the same reason the chown is: a commander with no // credential is degraded, and refusing to create it would be the worse trade — but it must be TOLD, // or it will find its own way to the machine, which is how this started. let credentialPath: string | null = null; let credentialError: string | null = null; try { ({ path: credentialPath } = await writeCommanderCredential({ userId: params.userId, email, dirName })); } catch (err) { credentialError = err instanceof Error ? err.message : 'could not mint a credential'; } writeFileSync( join(dir, 'CLAUDE.md'), commanderClaudeMd({ name: params.name, referencePath: params.referencePath, loopPidPath: loopPidPath(email, dirName), spawnedPath: spawnedRecordPath(email, dirName), credentialPath, }), ); // Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself. const ownershipError = await giveToAccount(email, dir); return { dir, ownershipError, credentialError }; }