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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user