Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78bf63c209 | ||
|
|
347acf0171 | ||
|
|
766b18b7f9 | ||
|
|
e4f5e03105 | ||
|
|
3b0083aec2 |
@@ -0,0 +1,99 @@
|
||||
# 2026.08.29.01 — deploy report
|
||||
|
||||
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
|
||||
|
||||
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
|
||||
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
|
||||
|
||||
## The two facts this release is about
|
||||
|
||||
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
|
||||
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
|
||||
v24) as the most likely failure; it was not one.
|
||||
|
||||
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
|
||||
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
|
||||
outstanding on any host.
|
||||
|
||||
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
|
||||
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
|
||||
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
|
||||
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
|
||||
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
|
||||
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
|
||||
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
|
||||
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
|
||||
|
||||
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
|
||||
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
|
||||
|
||||
## Schema
|
||||
|
||||
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
|
||||
taking its word:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. No table, column or constraint in this release, so the "check the database by query"
|
||||
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
|
||||
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
|
||||
release.
|
||||
|
||||
## The one deviation, and why
|
||||
|
||||
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
|
||||
|
||||
`bun run plugins:update` exited 1 with:
|
||||
|
||||
```
|
||||
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
|
||||
```
|
||||
|
||||
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
|
||||
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
|
||||
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
|
||||
release is about to add.
|
||||
|
||||
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
|
||||
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
|
||||
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
|
||||
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
|
||||
|
||||
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
|
||||
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
|
||||
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
|
||||
does not make.
|
||||
|
||||
## What else moved
|
||||
|
||||
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
|
||||
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
|
||||
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
|
||||
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
|
||||
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
|
||||
restart officer`, per host, by name.
|
||||
|
||||
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
|
||||
restart. Nothing there was named or touched.
|
||||
|
||||
## Notes, not problems
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||
cannot touch it. No host had tracked changes; no work was discarded anywhere.
|
||||
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
|
||||
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
|
||||
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
|
||||
ffmpeg is on the restarted process's PATH. Verified, not assumed.
|
||||
|
||||
## For the owner
|
||||
|
||||
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
|
||||
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
|
||||
not necessarily present for the running process. Restart it deliberately when you are happy with the
|
||||
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
|
||||
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
|
||||
dependency.
|
||||
@@ -0,0 +1,119 @@
|
||||
# 2026.08.29.02 — a plugin can ship a commander
|
||||
|
||||
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
|
||||
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
|
||||
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
|
||||
for each host.
|
||||
|
||||
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
|
||||
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
|
||||
where the standard `plugins:update` ran before the plan's install.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
|
||||
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
|
||||
before it can arm.
|
||||
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
|
||||
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
|
||||
- Marketplace, file browser and Convert Image work from earlier today rides along.
|
||||
|
||||
## Schema — CHANGED. `db:push` is required.
|
||||
|
||||
```
|
||||
command_hq_sessions
|
||||
+ plugin text null which plugin's template made this commander
|
||||
+ plugin_config jsonb null the owner's answers to that template's inputs
|
||||
```
|
||||
|
||||
Verified as the whole of it:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
|
||||
# → src/databases/officer_db/src/command-hq/queries.ts
|
||||
# src/databases/officer_db/src/command-hq/schema.ts
|
||||
```
|
||||
|
||||
`queries.ts` is code, not schema. The only structural change is the two columns above.
|
||||
|
||||
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
|
||||
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
|
||||
create-then-constrain here, and no `--force` is needed or permitted.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
|
||||
`pm_cwd`:
|
||||
|
||||
### 1. Back the database up first
|
||||
|
||||
```bash
|
||||
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
|
||||
```
|
||||
|
||||
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
|
||||
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
|
||||
|
||||
### 2. Push the schema
|
||||
|
||||
```bash
|
||||
bun db:push < /dev/null
|
||||
```
|
||||
|
||||
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
|
||||
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
|
||||
|
||||
### 3. VERIFY BY QUERY. The exit code is not the answer.
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
|
||||
const r = await db.execute(sql\`select column_name from information_schema.columns
|
||||
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
|
||||
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
Must print exactly:
|
||||
|
||||
```
|
||||
plugin plugin_config
|
||||
```
|
||||
|
||||
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
|
||||
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||
|
||||
### 5. Check it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are both healthy.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
|
||||
| `alpha` | Nothing special. |
|
||||
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||
| `production-pertento-bridge` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | Nothing special. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
|
||||
the verification query**, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
|
||||
thing this release actually changes about a machine.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.29.01",
|
||||
"version": "2026.08.29.02",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
plugin: string | null;
|
||||
pluginConfig: Record<string, string> | null;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
watcherArmedAt: Date | null;
|
||||
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
plugin: commandHqSessions.plugin,
|
||||
pluginConfig: commandHqSessions.pluginConfig,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
||||
@@ -85,7 +89,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 +122,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
|
||||
@@ -155,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
|
||||
export async function listCommandHqSessions(
|
||||
userId: number,
|
||||
categoryId?: number | null,
|
||||
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
|
||||
plugin?: string,
|
||||
): Promise<CommandHqSessionRow[]> {
|
||||
const where =
|
||||
categoryId === undefined
|
||||
const where = plugin
|
||||
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
|
||||
: categoryId === undefined
|
||||
? eq(commandHqSessions.userId, userId)
|
||||
: categoryId === null
|
||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||
@@ -257,6 +275,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({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
serial,
|
||||
text,
|
||||
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
|
||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||
*/
|
||||
referencePaths: text('reference_paths')
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
referencePaths: text('reference_paths').array().notNull().default([]),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
|
||||
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
||||
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
||||
watcherArmed: boolean('watcher_armed').notNull().default(false),
|
||||
|
||||
/**
|
||||
* The plugin that shipped this commander's template, or null for one somebody made themselves.
|
||||
*
|
||||
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
|
||||
* provenance should be structural. A category called "av-club" is a string an owner can rename,
|
||||
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
|
||||
* with nothing on disk saying otherwise.
|
||||
*
|
||||
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
|
||||
* still in its directory, and the UI can say what it came from rather than pretending it was
|
||||
* always hand-made.
|
||||
*/
|
||||
plugin: text('plugin'),
|
||||
|
||||
/**
|
||||
* The owner's answers to the template's `inputs`, or null when there was no template.
|
||||
*
|
||||
* Held here rather than only in the generated watcher script because the form has to be
|
||||
* re-openable: somebody changing which directory is watched should see what it is watching now,
|
||||
* not an empty form. The script is generated FROM this, so this is the source of truth.
|
||||
*/
|
||||
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
|
||||
/**
|
||||
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
|
||||
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { capabilitySources } from '../../capability-sources';
|
||||
|
||||
// Bun's own YAML, the same one `task-files.ts` uses. Not the `yaml` package — it is not a dependency,
|
||||
// and adding one to read four lines of frontmatter is not a trade worth making.
|
||||
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
|
||||
|
||||
/**
|
||||
* Commanders a plugin ships, ready to be created but not running.
|
||||
*
|
||||
* ── Presence is the declaration ──
|
||||
*
|
||||
* A plugin with a `commanders/<name>/COMMANDER.md` offers one. Nothing is added to `manifest.ts`, for
|
||||
* the same reason `tasks/` and `skills/` are not declared there: the directory already says it, and a
|
||||
* second place to say it is a second place for the two to disagree.
|
||||
*
|
||||
* Read IN PLACE, and only from installed-and-enabled plugins — `capabilitySources` handles both. So a
|
||||
* disabled plugin stops offering its commander, and `plugins:update` changes the template without
|
||||
* anything having to be republished.
|
||||
*
|
||||
* ── A template is not a commander ──
|
||||
*
|
||||
* What is here is an OFFER. Nothing runs, nothing is scheduled, and no directory exists until the
|
||||
* owner creates one from it and fills in the inputs. That is the whole shape the owner asked for:
|
||||
* a plugin may propose recurring work; only a person may start it.
|
||||
*
|
||||
* ── Why the inputs reuse TASK.md's vocabulary ──
|
||||
*
|
||||
* `inputs:` here is the same structure a task declares, so the same form renderer draws it and the
|
||||
* same validation applies. A commander asking "which folder, which extensions, what output" is asking
|
||||
* exactly what a task asks, and inventing a second schema for it would mean two form renderers to
|
||||
* keep in step for no gain.
|
||||
*/
|
||||
|
||||
export type CommanderInput = {
|
||||
type: string;
|
||||
description?: string;
|
||||
default?: string;
|
||||
options?: string[];
|
||||
/** Unset and required means the commander cannot arm. See `missingInputs`. */
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PluginCommanderTemplate = {
|
||||
/** Directory name, unique within its plugin — `heic-to-webp`. */
|
||||
dirName: string;
|
||||
/** The plugin that ships it. Never null: an owner's own commanders do not come from a template. */
|
||||
plugin: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** What it watches, in a sentence, for the card. */
|
||||
watches?: string;
|
||||
inputs: Record<string, CommanderInput>;
|
||||
/** The prose that becomes the commander's `CLAUDE.md`. */
|
||||
body: string;
|
||||
/** Absolute path to the template directory, so the watcher script can be read at creation. */
|
||||
dir: string;
|
||||
};
|
||||
|
||||
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { fm: {}, body: raw };
|
||||
try {
|
||||
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]! };
|
||||
} catch {
|
||||
// A template with unparseable frontmatter is skipped by the caller rather than crashing the list —
|
||||
// one plugin's bad YAML must not take away every other plugin's commanders.
|
||||
return { fm: {}, body: match[2]! };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPluginCommanders(): Promise<PluginCommanderTemplate[]> {
|
||||
const sources = await capabilitySources('commanders');
|
||||
const found: PluginCommanderTemplate[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
// Only plugins offer these. The owner's own `capabilities/commanders/` is not a thing that exists:
|
||||
// a commander somebody makes themselves is made in the UI, not from a template on disk.
|
||||
if (!source.plugin) continue;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(source.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue; // most plugins ship none — ordinary, not a fault
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = join(source.dir, entry.name);
|
||||
const file = Bun.file(join(dir, 'COMMANDER.md'));
|
||||
if (!(await file.exists())) continue;
|
||||
|
||||
const { fm, body } = parse(await file.text());
|
||||
if (!fm.name) continue; // no name means the frontmatter did not parse — skip it quietly
|
||||
|
||||
found.push({
|
||||
dirName: entry.name,
|
||||
plugin: source.plugin,
|
||||
name: String(fm.name),
|
||||
description: String(fm.description ?? ''),
|
||||
watches: fm.watches ? String(fm.watches) : undefined,
|
||||
inputs: (fm.inputs as Record<string, CommanderInput>) ?? {},
|
||||
body,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return found.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function getPluginCommander(plugin: string, dirName: string): Promise<PluginCommanderTemplate | null> {
|
||||
// Both halves reach this from a request body and are joined onto a path below.
|
||||
const unsafe = (v: string) => !v || v.includes('/') || v.includes('\\') || v === '.' || v === '..';
|
||||
if (unsafe(plugin) || unsafe(dirName)) return null;
|
||||
|
||||
const all = await listPluginCommanders();
|
||||
return all.find((t) => t.plugin === plugin && t.dirName === dirName) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which required inputs have no answer.
|
||||
*
|
||||
* This is the "configure before running" rule, and it is enforced at ARM time rather than at creation.
|
||||
* Creating a half-configured commander is fine and useful — it exists, it is visible, and it can be
|
||||
* finished later. Arming one is what would run somebody's disk against a blank setting.
|
||||
*/
|
||||
export function missingInputs(
|
||||
inputs: Record<string, CommanderInput>,
|
||||
config: Record<string, string> | null | undefined,
|
||||
): string[] {
|
||||
return Object.entries(inputs)
|
||||
.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';
|
||||
}
|
||||
@@ -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';
|
||||
@@ -201,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
|
||||
watcherLastFiredAt: Date | null;
|
||||
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
||||
cost: CommandHqCostSummary;
|
||||
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
|
||||
plugin?: string | null;
|
||||
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
|
||||
pluginConfig?: Record<string, string> | null;
|
||||
/**
|
||||
* Created, never opened — so there is no transcript behind it and its id is not a session id.
|
||||
*
|
||||
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
|
||||
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
|
||||
*/
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
@@ -222,7 +235,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const categoryParam = ctx.req.query('category');
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
|
||||
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
|
||||
which matches nothing and looks exactly like "this plugin has no commanders".
|
||||
*/
|
||||
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
|
||||
const categoryId =
|
||||
pluginFilter || categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||
@@ -235,7 +256,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
|
||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||
const summaries = withTranscript
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||
@@ -258,6 +279,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
),
|
||||
]);
|
||||
|
||||
/*
|
||||
A commander CREATED but never opened has no transcript, and every branch above drops it.
|
||||
|
||||
That was right when the only way to get a row was to start a conversation. It is wrong now: a
|
||||
commander made from a plugin's template arrives deliberately not running, and dropping it means the
|
||||
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
|
||||
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
|
||||
|
||||
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
|
||||
conversation failed to start, and a tombstone for it is what the design deliberately refused.
|
||||
*/
|
||||
const pending = rows
|
||||
.filter((row) => row.sessionId === null && row.plugin)
|
||||
.map(
|
||||
(row) =>
|
||||
({
|
||||
id: `pending-${row.id}`,
|
||||
title: row.name ?? 'Commander',
|
||||
dirName: row.dirName,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: [],
|
||||
running: false,
|
||||
watcherArmed: false,
|
||||
watcherArmedAt: null,
|
||||
watcherStoppedAt: null,
|
||||
watcherLastFiredAt: null,
|
||||
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
pending: true,
|
||||
}) as unknown as CommanderSummary,
|
||||
);
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId!)!,
|
||||
@@ -277,6 +331,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
|
||||
// it from a category name somebody can rename.
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
||||
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
||||
@@ -288,7 +346,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
return ctx.json({ commanders });
|
||||
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
|
||||
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
|
||||
return ctx.json({ commanders: [...pending, ...commanders] });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -463,6 +523,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
@@ -525,6 +587,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 +612,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 +635,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}`);
|
||||
|
||||
@@ -43,7 +43,9 @@ export type CapabilitySource = {
|
||||
plugin: string | null;
|
||||
};
|
||||
|
||||
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
|
||||
export async function capabilitySources(
|
||||
type: ItemType | 'affordances' | 'notifications' | 'commanders',
|
||||
): Promise<CapabilitySource[]> {
|
||||
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||
import { useCommandHqCategories } from './useCommandHq';
|
||||
import type { CommandHqCategoryFilter } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
|
||||
// Which plugins have anything to offer. Derived from the templates rather than from the installed
|
||||
// plugin list: a plugin that ships no commander should not get a row that is always empty.
|
||||
const { templates } = usePluginCommanderTemplates();
|
||||
const plugins = [...new Set(templates.map((template) => template.plugin))].sort();
|
||||
const { categories, createCategory } = useCommandHqCategories();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
|
||||
to={commandHqListPath(category.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/*
|
||||
One row per plugin that offers commanders, below the owner's own categories and separated from
|
||||
them.
|
||||
|
||||
Separated because they are not the same kind of thing. A category is the owner's filing and they
|
||||
can rename or delete it; a plugin is where something came from and they cannot. Mixing them into
|
||||
one list would invite the reasonable assumption that "av-club" is a folder they made, and the
|
||||
first thing somebody does with a folder they made is rename it.
|
||||
|
||||
Shown even when the plugin has no commanders yet — that is the whole point. The row is how you
|
||||
find out a plugin is offering one.
|
||||
*/}
|
||||
{plugins.length > 0 && (
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
<span className="block px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||
{t('commandHq.fromPlugins')}
|
||||
</span>
|
||||
{plugins.map((plugin) => (
|
||||
<Row
|
||||
key={plugin}
|
||||
label={plugin}
|
||||
active={active === `plugin:${plugin}`}
|
||||
to={commandHqListPath(`plugin:${plugin}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{adding && (
|
||||
// No submit button — a form with exactly one text field still submits on Enter.
|
||||
<form onSubmit={submitNew}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useCommandHqCommanderActions,
|
||||
useCreateCommander,
|
||||
} from './useCommandHq';
|
||||
import type { CommandHqCommanderCreated } from './types';
|
||||
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -246,7 +246,7 @@ function NewCommanderSetup({
|
||||
category,
|
||||
onCreated,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
category: CommandHqCategoryFilter;
|
||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||
}) {
|
||||
/*
|
||||
@@ -335,7 +335,7 @@ function ActiveNewCommander({
|
||||
onStarted,
|
||||
}: {
|
||||
commander: CommandHqCommanderCreated;
|
||||
category: number | 'none' | undefined;
|
||||
category: CommandHqCategoryFilter;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const { t, tUi, locale } = useTranslation();
|
||||
@@ -446,13 +446,7 @@ function ActiveNewCommander({
|
||||
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
||||
* socket dropped").
|
||||
*/
|
||||
function NewCommander({
|
||||
category,
|
||||
onStarted,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
|
||||
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
||||
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
||||
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Plus, Trash2, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { PluginCommanderOffers } from './PluginCommanderOffers';
|
||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
||||
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
|
||||
row.
|
||||
*/}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{/* Only on a plugin's own filter. On "All commanders" this would put every plugin's offers above
|
||||
a list that is mostly not theirs, which is a different screen pretending to be this one. */}
|
||||
{typeof category === 'string' && category.startsWith('plugin:') && (
|
||||
<PluginCommanderOffers plugin={category.slice('plugin:'.length)} />
|
||||
)}
|
||||
|
||||
{commanders.map((commander) => (
|
||||
<Card
|
||||
key={commander.id}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Package, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'officerdev';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||
import type { PluginCommanderTemplate } from './types';
|
||||
|
||||
/**
|
||||
* What a plugin offers but has not been started.
|
||||
*
|
||||
* ── Offers sit ABOVE the grid, not inside it ──
|
||||
*
|
||||
* A commander in the grid is running, has a transcript and has cost something. An offer is a
|
||||
* description on disk. Drawing them as the same kind of card, distinguished by a badge, makes the two
|
||||
* look interchangeable — and the first consequence is somebody looking for the transcript of a thing
|
||||
* that has never run.
|
||||
*
|
||||
* ── An offer stays visible after it has been used ──
|
||||
*
|
||||
* Deliberately: a plugin's commander is a template, and there is no reason to have only one. Watching
|
||||
* two different folders for two different formats is two commanders from the same offer, and hiding
|
||||
* the offer once used would make the second one look impossible.
|
||||
*/
|
||||
|
||||
export const PluginCommanderOffers = ({ plugin }: { plugin: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { templates, createFromTemplate } = usePluginCommanderTemplates();
|
||||
const [configuring, setConfiguring] = useState<PluginCommanderTemplate | null>(null);
|
||||
|
||||
const offers = templates.filter((template) => template.plugin === plugin);
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<Package className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t('commandHq.available')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 pt-1">
|
||||
{offers.map((offer) => (
|
||||
<div key={offer.dirName} className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{offer.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{offer.description}</p>
|
||||
{offer.watches && <p className="pt-0.5 text-xs text-muted-foreground/70">{offer.watches}</p>}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={configuring?.dirName === offer.dirName ? 'secondary' : 'outline'}
|
||||
className="shrink-0"
|
||||
onClick={() => setConfiguring(configuring?.dirName === offer.dirName ? null : offer)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t('commandHq.configureAndCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{configuring?.dirName === offer.dirName && (
|
||||
<ConfigureForm
|
||||
template={offer}
|
||||
busy={createFromTemplate.isPending}
|
||||
onCancel={() => setConfiguring(null)}
|
||||
onSubmit={(name, config) => {
|
||||
createFromTemplate.mutate(
|
||||
{ plugin: offer.plugin, template: offer.dirName, name, config },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setConfiguring(null);
|
||||
// Named, because it is now in the grid below among things that look alike.
|
||||
toast.success(`${name} created — it is not running yet`);
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'could not create it'),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The template's own inputs, drawn from its declaration.
|
||||
*
|
||||
* Nothing here knows what av-club's commander wants. `options` becomes a select, `boolean` becomes
|
||||
* yes/no, everything else is a text field — the same three shapes a task's inputs use, because they
|
||||
* are the same declaration.
|
||||
*/
|
||||
const ConfigureForm = ({
|
||||
template,
|
||||
busy,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
template: PluginCommanderTemplate;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string, config: Record<string, string>) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(template.name);
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(Object.entries(template.inputs).map(([key, spec]) => [key, spec.default ?? ''])),
|
||||
);
|
||||
|
||||
const missing = Object.entries(template.inputs)
|
||||
.filter(([key, spec]) => spec.required && !values[key]?.trim())
|
||||
.map(([key]) => key);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-3 flex flex-col gap-3 border-t border-border pt-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (missing.length === 0 && name.trim()) onSubmit(name.trim(), values);
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('commandHq.name')}</span>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} className="text-sm" />
|
||||
</label>
|
||||
|
||||
{Object.entries(template.inputs).map(([key, spec]) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{key.replace(/_/g, ' ')}
|
||||
{spec.required && <span className="text-destructive"> *</span>}
|
||||
</span>
|
||||
|
||||
{spec.options ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{spec.options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
values[key] === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : spec.type === 'boolean' ? (
|
||||
<div className="flex gap-1">
|
||||
{['false', 'true'].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
(values[key] || 'false') === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{option === 'true' ? 'Yes' : 'No'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={values[key] ?? ''}
|
||||
onChange={(event) => setValues((v) => ({ ...v, [key]: event.target.value }))}
|
||||
placeholder={spec.type === 'directory' ? '/home/you/folder' : ''}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{spec.description && <span className="text-[11px] text-muted-foreground/70">{spec.description}</span>}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={busy || missing.length > 0 || !name.trim()}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{t('commandHq.configureAndCreate')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/* Named, so it is obvious WHICH field is stopping the button rather than hunting for a red mark. */}
|
||||
{missing.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">needs {missing.join(', ').replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,9 @@ export const commandHqSessionPath = (sessionId: string, category?: CommandHqCate
|
||||
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
||||
if (raw === null) return undefined;
|
||||
if (raw === 'none') return 'none';
|
||||
// `plugin:av-club`. Checked before the numeric parse, since an appName never parses as a number and
|
||||
// would otherwise fall through to `undefined` and silently show every commander.
|
||||
if (raw.startsWith('plugin:') && raw.length > 'plugin:'.length) return raw as CommandHqCategoryFilter;
|
||||
const id = Number(raw);
|
||||
return Number.isFinite(id) ? id : undefined;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
|
||||
totalUsd: number;
|
||||
};
|
||||
|
||||
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
|
||||
export type CommandHqCategoryFilter = number | 'none' | undefined;
|
||||
/**
|
||||
* `category` query value: omitted (all), `'none'` (uncategorised), a category id, or `plugin:<appName>`.
|
||||
*
|
||||
* The plugin form is not a category and deliberately does not pretend to be one. A commander's category
|
||||
* is the owner's filing; which plugin shipped it is a fact about where it came from, and the two are
|
||||
* independent — a plugin's commander can also be filed under "Nightly" without either meaning changing.
|
||||
* One query parameter carries both because they are both "which subset of the grid", and that is what
|
||||
* this parameter has always meant.
|
||||
*/
|
||||
export type CommandHqCategoryFilter = number | 'none' | `plugin:${string}` | undefined;
|
||||
|
||||
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
||||
export type CommandHqCommander = {
|
||||
@@ -100,3 +108,23 @@ export type CommandHqFire = {
|
||||
taskId: string;
|
||||
firedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A commander a plugin OFFERS but which does not exist yet.
|
||||
*
|
||||
* Deliberately a different type from `CommandHqCommander`, and fetched from a different endpoint. One
|
||||
* runs, has a transcript and costs money; the other is a description on disk. Modelling them as one
|
||||
* type with an `exists` flag would mean every consumer checking the flag before touching anything, and
|
||||
* the one that forgets renders a card with a cost of zero for something that was never started.
|
||||
*/
|
||||
export type PluginCommanderTemplate = {
|
||||
dirName: string;
|
||||
plugin: string;
|
||||
name: string;
|
||||
description: string;
|
||||
watches?: string;
|
||||
inputs: Record<
|
||||
string,
|
||||
{ type: string; description?: string; default?: string; options?: string[]; required?: boolean }
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CommandHqCommander,
|
||||
CommandHqCommanderCreated,
|
||||
CommandHqCommanderDetail,
|
||||
PluginCommanderTemplate,
|
||||
} from './types';
|
||||
|
||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the installed plugins offer, and making one real.
|
||||
*
|
||||
* The list is cheap and changes only when a plugin is installed, updated or disabled — so it is not
|
||||
* refetched on a window focus. A plugin the owner just installed appears when Command HQ next mounts,
|
||||
* which is the same moment its dock tile does.
|
||||
*/
|
||||
export const usePluginCommanderTemplates = () => {
|
||||
const { get, post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ templates: PluginCommanderTemplate[] }>({
|
||||
queryKey: ['command-hq', 'plugin-commanders'],
|
||||
queryFn: () => get('/command-hq/plugin-commanders'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createFromTemplate = useMutation({
|
||||
mutationFn: (input: { plugin: string; template: string; name: string; config: Record<string, string> }) =>
|
||||
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||
// The grid reads `commanders`, and a newly created one has to appear in it without a reload —
|
||||
// creating from a template is the one moment somebody is certain something should have happened.
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||
});
|
||||
|
||||
return { templates: data?.templates ?? EMPTY_TEMPLATES, isLoading, createFromTemplate };
|
||||
};
|
||||
|
||||
// Module-level, so an empty result is the SAME array every render. A fresh `[]` in the fallback is a new
|
||||
// identity each time, which re-runs every effect and memo that depends on it — the trap this file already
|
||||
// avoids for categories and commanders.
|
||||
const EMPTY_TEMPLATES: PluginCommanderTemplate[] = [];
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
|
||||
"startBlocked": "{{missing}} — dann startet dies ihn.",
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen",
|
||||
"fromPlugins": "Von Plugins",
|
||||
"available": "Verfügbar",
|
||||
"configureAndCreate": "Einrichten und erstellen",
|
||||
"notCreatedYet": "Noch nicht erstellt — richten Sie es ein, um zu starten."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Neuer Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
||||
"startBlocked": "{{missing}} — then this starts it.",
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}"
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}",
|
||||
"fromPlugins": "From plugins",
|
||||
"available": "Available",
|
||||
"configureAndCreate": "Configure and create",
|
||||
"notCreatedYet": "Not created yet — configure it to start."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "New Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
||||
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}",
|
||||
"fromPlugins": "De plugins",
|
||||
"available": "Disponible",
|
||||
"configureAndCreate": "Configurar y crear",
|
||||
"notCreatedYet": "Aún no creado — configúralo para empezar."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Nuevo chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
|
||||
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}",
|
||||
"fromPlugins": "Depuis les plugins",
|
||||
"available": "Disponible",
|
||||
"configureAndCreate": "Configurer et créer",
|
||||
"notCreatedYet": "Pas encore créé — configurez-le pour commencer."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Nouveau Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
||||
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}",
|
||||
"fromPlugins": "De plugins",
|
||||
"available": "Disponível",
|
||||
"configureAndCreate": "Configurar e criar",
|
||||
"notCreatedYet": "Ainda não criado — configure para começar."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Novo chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
||||
"startBlocked": "{{missing}} — sedan startar detta den.",
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}"
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}",
|
||||
"fromPlugins": "Från plugin",
|
||||
"available": "Tillgänglig",
|
||||
"configureAndCreate": "Konfigurera och skapa",
|
||||
"notCreatedYet": "Inte skapad än — konfigurera för att starta."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Ny chatt",
|
||||
|
||||
Reference in New Issue
Block a user