create a commander from a plugin's template, and refuse to arm a blank one

second half of the backend. GET /command-hq/plugin-commanders lists what the
installed plugins offer; POST /commanders takes {plugin, template, config} and
builds one from it.

the list is SEPARATE from GET /commanders, which returns what exists. merging a
thing that runs with a thing that could be made, distinguished by a flag, means
every consumer has to remember which it is holding.

`watch_dir` doubles as the reference path when a template supplies one. asking for
a reference path AND a watch directory is asking the same question twice and
inviting the two answers to disagree.

THREE FILES, EACH A COPY rather than a reference into the plugin:

  state/config.env   what the owner answered, in the form the watcher sources
  watch.sh           copied, so plugins:update cannot change the script a commander
                     is already running — a watcher whose code moves under it while
                     armed is a thing nobody could debug
  AGENTS.md          the template's prose, appended to the brief already written

config.env is generated with every value SINGLE-QUOTED, and that is not decoration:
WATCH_DIR is a path somebody typed, and this fleet has directories called `ABOUT US`
and `70´S`. unquoted, the first space ends the assignment and the watcher watches
the wrong folder — with `rm` downstream of that. verified by sourcing both that path
and one containing an apostrophe in a real shell.

THE ARM GUARD LIVES IN armCommandHqWatcher, not in a route. arming is not a button:
it happens when the agent starts a tracked background job and the platform notices.
a check anywhere else is one the agent routes around by simply starting its loop,
which is exactly what it does. a row with no template is unaffected.

verified end to end on the running server: the template listed, a commander created
from it, watch.sh + config.env (mode 600) + the brief on disk, config.env sourcing
cleanly, and the watcher refusing to start when config.env is absent — which is the
configure-before-running rule enforcing itself at the last line of defence.

still to come: the Command HQ section that shows the offers and draws the form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 02:58:04 +01:00
co-authored by Claude Opus 5
parent e4f5e03105
commit 766b18b7f9
3 changed files with 155 additions and 4 deletions
@@ -85,7 +85,16 @@ const SESSION_ROW_COLUMNS = {
createdAt: commandHqSessions.createdAt,
};
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
export type NewCommander = {
userId: number;
name: string;
referencePath: string;
categoryId?: number | null;
/** The plugin whose template this was made from, or null for one somebody made themselves. */
plugin?: string | null;
/** The owner's answers to that template's inputs. Null when there was no template. */
pluginConfig?: Record<string, string> | null;
};
/**
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
@@ -109,6 +118,8 @@ export async function createCommander(input: NewCommander): Promise<CommandHqSes
referencePaths: [input.referencePath],
name: input.name,
categoryId: input.categoryId ?? null,
plugin: input.plugin ?? null,
pluginConfig: input.pluginConfig ?? null,
})
.returning({ id: commandHqSessions.id });
const [row] = await tx
@@ -257,6 +268,33 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
* longer the current one.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
/*
A commander made from a plugin's template cannot arm until it has been configured.
Enforced HERE rather than in a route because arming is not a button: it happens when the agent
starts a tracked background job, and the platform notices. A check anywhere else would be a check
the agent could route around simply by starting its loop, which is exactly what it does.
Creating a half-configured commander stays fine and useful — it exists, it is visible, and it can be
finished later. It is ARMING one that would run somebody's disk against a blank setting.
A row with no template is unaffected: `plugin` is null and this returns immediately.
*/
const [existing] = await db
.select({ plugin: commandHqSessions.plugin, pluginConfig: commandHqSessions.pluginConfig })
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.limit(1);
if (existing?.plugin) {
// Only the presence of answers is checked here, not which ones are required — that needs the
// template, which is a filesystem read and does not belong in the query layer. The route that
// creates the commander writes every input it was given, so an empty object means nothing was
// configured at all, which is the case worth stopping.
const answered = Object.values(existing.pluginConfig ?? {}).filter((v) => String(v ?? '').trim());
if (answered.length === 0) return false;
}
const rows = await db
.update(commandHqSessions)
.set({
@@ -135,3 +135,36 @@ export function missingInputs(
.filter(([key, spec]) => spec.required && !(config?.[key] ?? spec.default ?? '').trim())
.map(([key]) => key);
}
/**
* Everything a created commander needs on disk beyond its `CLAUDE.md`.
*
* ── `config.env`, not a JSON file ──
*
* The watcher is a shell script and this is what it sources. A JSON config would mean the script
* parsing JSON in bash, which is either `jq` (another dependency for four values) or something
* hand-rolled that breaks on the first path with a space in it. The database row remains the source
* of truth; this is a projection of it that bash can read.
*
* ── Every value is quoted, and that is not decoration ──
*
* `WATCH_DIR` is a path the owner typed, and this fleet has directories called `ABOUT US` and
* `70´S`. Unquoted, the first space ends the assignment and the watcher silently watches the wrong
* folder — or, with `rm` involved downstream, the wrong folder's contents.
*/
export function configEnv(
inputs: Record<string, CommanderInput>,
config: Record<string, string> | null | undefined,
): string {
const lines = ['# Written by Officer when this commander was created. Edit through the UI, not here.'];
for (const [key, spec] of Object.entries(inputs)) {
const value = config?.[key] ?? spec.default ?? '';
// Single quotes, with any embedded single quote closed and re-opened around an escaped one — the
// standard shell trick, and the only form that is safe for arbitrary text.
const quoted = `'${String(value).replace(/'/g, `'\\''`)}'`;
lines.push(`${key.toUpperCase()}=${quoted}`);
}
return lines.join('\n') + '\n';
}
+83 -3
View File
@@ -1,11 +1,12 @@
import type { Context } from 'hono';
import { randomUUID } from 'node:crypto';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { toShellUsername, getCommanderDir } from '@@/data-path';
import { toShellUsername, getCommanderDir, getCommanderStateDir } from '@@/data-path';
import {
forgetChatSessions,
getChatEventsSince,
@@ -17,6 +18,7 @@ import * as sidecar from '@@/sidecar-registry';
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
import { sessionKeyForTranscript } from '../chat/websocket';
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
import { configEnv, getPluginCommander, listPluginCommanders, missingInputs } from './plugin-commanders';
import { createCommanderDirectory } from './directory';
import { commanderKeyName } from './credential';
import type { ServerMessage } from '../chat/types';
@@ -525,6 +527,21 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
* design removes — a client that has to remember to follow through, on a value the server already knows
* first.
*/
/**
* What the installed plugins OFFER. Not commanders — templates for them.
*
* Separate from `GET /commanders`, which lists what exists. Merging the two would put a thing that
* runs and a thing that could be made in one list, distinguished by a flag, and every consumer would
* have to remember which it was holding.
*/
commandHqRouter.get('/plugin-commanders', async (ctx) => {
const templates = await listPluginCommanders();
// The body is the commander's future CLAUDE.md and can be pages long; the list does not need it.
return ctx.json({
templates: templates.map(({ body: _body, dir: _dir, ...rest }) => rest),
});
});
commandHqRouter.post('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
@@ -535,6 +552,11 @@ commandHqRouter.post('/commanders', async (ctx) => {
/** A chat context to watch, instead of a path — see below. */
context?: string;
contextId?: string;
/** Create from a plugin's template: the plugin's appName and the template's directory name. */
plugin?: string;
template?: string;
/** The owner's answers to that template's inputs. */
config?: Record<string, string>;
};
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
const name = body.name?.trim();
@@ -553,15 +575,73 @@ commandHqRouter.post('/commanders', async (ctx) => {
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
and an account allowed to walk the disk. The button asks for neither.
*/
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
/*
A template can supply the reference path from its own configuration.
`watch_dir` is what this kind of commander is about, so making the owner give a reference path AND a
watch directory would be asking the same question twice and inviting the two to disagree.
*/
const template = body.plugin && body.template ? await getPluginCommander(body.plugin, body.template) : null;
if (body.plugin && body.template && !template) {
throw errors.BAD_REQUEST(`No commander template "${body.template}" in plugin "${body.plugin}"`);
}
const fromConfig = template ? (body.config?.watch_dir ?? '').trim() : '';
const referencePath = body.context
? chatContextDir(body.context, body.contextId, who)
: body.referencePath?.trim() || fromConfig;
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
const row = await createCommander({
userId: user.id,
name,
referencePath,
categoryId: body.categoryId ?? null,
plugin: template?.plugin ?? null,
pluginConfig: template ? (body.config ?? {}) : null,
});
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
name,
referencePath,
userId: user.id,
});
/*
A template's own files, written after the directory exists.
Three things, and each is deliberately a COPY rather than a reference into the plugin:
state/config.env what the owner answered, in the form the watcher sources
watch.sh the loop, copied so `plugins:update` cannot change the script a commander is
already running — a watcher whose code moves under it while armed is a thing
nobody would be able to debug
AGENTS.md the template's prose, appended to the brief already written
A failure here is reported and does not undo the commander. It exists, it is visible, and a missing
config.env is exactly what the watcher refuses to start without — which is the configure-before-
running rule enforcing itself rather than a half-created row nobody can see.
*/
let templateError: string | null = null;
if (template) {
try {
const { writeFile, copyFile, appendFile } = await import('node:fs/promises');
const stateDir = getCommanderStateDir(who.email, row.dirName);
await writeFile(join(stateDir, 'config.env'), configEnv(template.inputs, body.config), { mode: 0o600 });
const script = join(template.dir, 'watch.sh');
if (await Bun.file(script).exists()) {
await copyFile(script, join(dir, 'watch.sh'));
await (await import('node:fs/promises')).chmod(join(dir, 'watch.sh'), 0o755);
}
if (template.body.trim()) {
await appendFile(join(dir, 'AGENTS.md'), `\n\n---\n\n${template.body.trim()}\n`);
}
} catch (err) {
templateError = err instanceof Error ? err.message : 'could not write the template files';
console.warn(`[command-hq] ${row.dirName}: template not fully written — ${templateError}`);
}
}
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
if (ownershipError) {
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);