classification/ with a directory per category and an unclassified/ that always exists, plus procedures/, reports/ and state/. named for the SHAPE, not the payload. the voice-memos commander called it transcripts/, which bakes one job into the layout — the same structure has to hold for a commander over email or photos, and none of those produce transcripts. and the distinction that makes the two records make sense together: the ledger says whether an item was HANDLED, the directory says where it BELONGS. unclassified/ is finished work waiting on a person rather than a failure, so nothing re-runs the action on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
322 lines
17 KiB
TypeScript
322 lines
17 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';
|
|
import { writeCommanderCredential } from './credential';
|
|
|
|
/*
|
|
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;
|
|
/** 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/<dirName>/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: <name>`, 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/<something>.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',
|
|
' <category>/ 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<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; 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 };
|
|
}
|