diff --git a/src/databases/officer_db/src/command-hq/queries.ts b/src/databases/officer_db/src/command-hq/queries.ts index 1639abba..f1537040 100644 --- a/src/databases/officer_db/src/command-hq/queries.ts +++ b/src/databases/officer_db/src/command-hq/queries.ts @@ -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 | 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 { + /* + 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({ diff --git a/src/servers/api/command-hq/plugin-commanders.ts b/src/servers/api/command-hq/plugin-commanders.ts index b8ac92d6..ea7e429d 100644 --- a/src/servers/api/command-hq/plugin-commanders.ts +++ b/src/servers/api/command-hq/plugin-commanders.ts @@ -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, + config: Record | 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'; +} diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index d737524e..42e6eee9 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -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; }; const body = await ctx.req.json().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}`);