Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f600d34d | ||
|
|
cbedfed2ba | ||
|
|
f9a3699603 | ||
|
|
75affec091 | ||
|
|
de9d229cfa | ||
|
|
cabc4825ef | ||
|
|
87ff6ed326 | ||
|
|
6d6e8a841e | ||
|
|
b7112f6e5d | ||
|
|
ee9106ef54 | ||
|
|
e4f1b75f9b | ||
|
|
4a206086af | ||
|
|
29d998b6c3 | ||
|
|
228f2f4638 |
@@ -194,7 +194,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). 27 entries as of 2026-08-13.
|
||||
never grantable at any level). **18 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 3 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
@@ -224,9 +224,20 @@ bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||
place to drift, and it drifts in the same edit that misses the doc.
|
||||
|
||||
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||
written had been wrong for over a week.
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
|
||||
@@ -20,7 +20,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**87 open, 25 closed**, across 29 themes.
|
||||
**88 open, 25 closed**, across 29 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
@@ -51,7 +51,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
|
||||
## Records — no items, kept for the reasoning
|
||||
@@ -69,7 +69,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
|
||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
||||
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
||||
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# 2026.08.22.02 — deploy report
|
||||
|
||||
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
|
||||
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
|
||||
|
||||
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
|
||||
`package.json` `2026.08.22.02`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
|
||||
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
|
||||
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
|
||||
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
|
||||
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
|
||||
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
|
||||
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
|
||||
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
|
||||
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
|
||||
|
||||
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
|
||||
|
||||
## Per host
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
|
||||
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
|
||||
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
|
||||
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
|
||||
|
||||
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
|
||||
the tree, the schema and the running process are all already on this release.
|
||||
|
||||
### `alpha` — done
|
||||
|
||||
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
|
||||
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
|
||||
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
|
||||
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
|
||||
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
|
||||
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
|
||||
|
||||
### `fofos` — done
|
||||
|
||||
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
|
||||
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
|
||||
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
|
||||
all 8 processes `online`. `theme_favorites` present.
|
||||
|
||||
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
|
||||
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
|
||||
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
|
||||
use `POSTGRES_URL`.
|
||||
|
||||
### `macbookpro` — done, awake
|
||||
|
||||
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
|
||||
like the rest. Pulled straight to `68d60e40`.
|
||||
|
||||
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
|
||||
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
|
||||
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
|
||||
|
||||
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
|
||||
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
|
||||
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
|
||||
|
||||
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
|
||||
unrelated to this release, but it appears in the raw output above every command.
|
||||
|
||||
### `edge-pertento-bridge` — done
|
||||
|
||||
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
|
||||
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
|
||||
processes `online`. `theme_favorites` present.
|
||||
|
||||
### `production-pertento-bridge` — done
|
||||
|
||||
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
|
||||
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
|
||||
`theme_favorites` present.
|
||||
|
||||
### `deadpool-rezio-bridge` — done
|
||||
|
||||
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
|
||||
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
|
||||
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
|
||||
|
||||
## What did not happen
|
||||
|
||||
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
|
||||
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
|
||||
what this agent executes.
|
||||
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
|
||||
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
|
||||
here rather than reporting only `officer` is the plan's explicit instruction.
|
||||
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
|
||||
Everything else was verified exactly as written.
|
||||
|
||||
## Claim record
|
||||
|
||||
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
|
||||
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
|
||||
cannot cause a second rollout.
|
||||
+3
-1
@@ -34,7 +34,9 @@
|
||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||
"setup": "bash scripts/install.sh",
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts"
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts",
|
||||
"docs:check": "bun run scripts/docs-check.ts",
|
||||
"skills:platform": "bun run scripts/gen-platform-skill.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -96,6 +96,21 @@ load-bearing. A missing report is a missing record; a missing lock is a second d
|
||||
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
|
||||
clone does not arrive believing it has deployed nothing.
|
||||
|
||||
## Before a release is tagged — not your job, but know it happened
|
||||
|
||||
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
|
||||
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
|
||||
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
|
||||
|
||||
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
|
||||
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
|
||||
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
|
||||
thirteen, listing eleven processes that do not run here while omitting six that do.
|
||||
|
||||
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
|
||||
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
|
||||
it would put a prose edit between two hosts.
|
||||
|
||||
## The release
|
||||
|
||||
The tag names the version. The commit it points at carries the rest:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Glob } from 'bun';
|
||||
|
||||
/*
|
||||
Which settings sections actually exist, read from the screens that declare them.
|
||||
|
||||
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
|
||||
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
|
||||
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
|
||||
narrow enough to be obvious when it breaks.
|
||||
*/
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
|
||||
|
||||
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
|
||||
export async function declaredSettingsPaths(): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
const glob = new Glob('**/*.tsx');
|
||||
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
|
||||
if (!base) continue;
|
||||
paths.add(base);
|
||||
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings routes Spotlight offers, and which of them nothing declares.
|
||||
*
|
||||
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
|
||||
* lucide and React.
|
||||
*/
|
||||
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
|
||||
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
|
||||
const declared = await declaredSettingsPaths();
|
||||
return { offered, missing: offered.filter((p) => !declared.has(p)) };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Does the documentation still match the code?
|
||||
|
||||
── Why a script and not a habit ──
|
||||
|
||||
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
|
||||
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
|
||||
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
|
||||
|
||||
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
|
||||
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
|
||||
number is exactly the thing nobody re-derives while editing a sentence around it.
|
||||
|
||||
Real examples this was written from, both live at the time:
|
||||
|
||||
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
|
||||
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
|
||||
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
|
||||
|
||||
── The rule for adding a check ──
|
||||
|
||||
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
|
||||
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
|
||||
the same edit that misses the doc.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type Finding = { file: string; claim: string; documented: string; actual: string };
|
||||
type CheckResult = { findings: Finding[]; skipped?: string };
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const WORKSPACE = resolve(ROOT, '..');
|
||||
|
||||
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
|
||||
|
||||
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
|
||||
const WORDS: Record<string, number> = {
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
thirteen: 13,
|
||||
fourteen: 14,
|
||||
fifteen: 15,
|
||||
sixteen: 16,
|
||||
seventeen: 17,
|
||||
eighteen: 18,
|
||||
nineteen: 19,
|
||||
twenty: 20,
|
||||
'twenty-one': 21,
|
||||
'twenty-two': 22,
|
||||
'twenty-three': 23,
|
||||
'twenty-four': 24,
|
||||
'twenty-five': 25,
|
||||
};
|
||||
const asNumber = (raw: string): number | null => {
|
||||
const digits = Number(raw);
|
||||
if (Number.isFinite(digits)) return digits;
|
||||
return WORDS[raw.toLowerCase()] ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A documented count, found by a pattern with one capture group, compared to the live value.
|
||||
*
|
||||
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
|
||||
*/
|
||||
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
|
||||
const body = read(file);
|
||||
if (!body) return { findings: [], skipped: `${file} not found` };
|
||||
const findings: Finding[] = [];
|
||||
for (const match of body.matchAll(pattern)) {
|
||||
const stated = asNumber(match[1] ?? '');
|
||||
if (stated === null || stated === actual) continue;
|
||||
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
|
||||
}
|
||||
return { findings };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const all: Finding[] = [];
|
||||
const skipped: string[] = [];
|
||||
const record = (r: CheckResult) => {
|
||||
all.push(...r.findings);
|
||||
if (r.skipped) skipped.push(r.skipped);
|
||||
};
|
||||
|
||||
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
record(
|
||||
checkCount(
|
||||
join(ROOT, 'CLAUDE.md'),
|
||||
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||
PERMISSIONS.length,
|
||||
'permission registry entries',
|
||||
),
|
||||
);
|
||||
|
||||
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
|
||||
// admission that it needs a check rather than a caveat.
|
||||
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
if (pm2.exitCode === 0) {
|
||||
try {
|
||||
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
|
||||
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
|
||||
record(
|
||||
checkCount(
|
||||
join(WORKSPACE, 'CLAUDE.md'),
|
||||
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
|
||||
officer,
|
||||
'officer PM2 entries',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
skipped.push('pm2 jlist did not parse');
|
||||
}
|
||||
} else {
|
||||
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
|
||||
skipped.push('pm2 unavailable — PM2 entry count unchecked');
|
||||
}
|
||||
|
||||
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
|
||||
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
|
||||
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
|
||||
const declared = await SPOTLIGHT_SETTINGS_PATHS();
|
||||
for (const bad of declared.missing) {
|
||||
all.push({
|
||||
file: 'Spotlight/useSpotlightItems.ts',
|
||||
claim: 'settings route offered by Spotlight',
|
||||
documented: bad,
|
||||
actual: 'no screen declares that section',
|
||||
});
|
||||
}
|
||||
|
||||
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
|
||||
// an agent quotes it to a user with no way to tell it is out of date.
|
||||
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
|
||||
const expected = await renderPlatformSkill();
|
||||
const onDisk = read(SKILL_PATH);
|
||||
if (onDisk === null) {
|
||||
all.push({
|
||||
file: 'capabilities/skills/platform/SKILL.md',
|
||||
claim: 'generated platform skill',
|
||||
documented: 'missing',
|
||||
actual: 'run `bun run skills:platform`',
|
||||
});
|
||||
} else if (onDisk !== expected) {
|
||||
all.push({
|
||||
file: 'capabilities/skills/platform/SKILL.md',
|
||||
claim: 'generated platform skill is stale',
|
||||
documented: `${onDisk.split('\n').length} lines on disk`,
|
||||
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
|
||||
});
|
||||
}
|
||||
|
||||
for (const note of skipped) console.log(` skipped: ${note}`);
|
||||
if (all.length === 0) {
|
||||
console.log('docs:check — every checkable claim matches the code');
|
||||
return;
|
||||
}
|
||||
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
|
||||
for (const f of all) {
|
||||
console.log(` ${f.file}`);
|
||||
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
|
||||
|
||||
── Why generated ──
|
||||
|
||||
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
|
||||
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
|
||||
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
|
||||
|
||||
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
|
||||
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
|
||||
and the prose is limited to what no registry can express.
|
||||
|
||||
── The split, and where the line is ──
|
||||
|
||||
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
|
||||
keyboard shortcuts
|
||||
PROSE what the platform IS, what a permission KIND means for a user, where to look next
|
||||
|
||||
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
|
||||
differs, so a stale skill is a failed check rather than a confident wrong answer.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
|
||||
|
||||
const KIND_MEANING: Record<string, string> = {
|
||||
core: 'every account has it and it cannot be denied — the caller acting on themselves',
|
||||
app: 'grantable per role, at read or write',
|
||||
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
|
||||
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
|
||||
admin: 'the server owner only',
|
||||
};
|
||||
|
||||
export async function renderPlatformSkill(): Promise<string> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||
|
||||
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||
for (const p of PERMISSIONS) {
|
||||
const list = byKind.get(p.kind) ?? [];
|
||||
list.push(p);
|
||||
byKind.set(p.kind, list as typeof PERMISSIONS);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const out = (s = '') => lines.push(s);
|
||||
|
||||
out('---');
|
||||
out('name: Officer platform');
|
||||
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||||
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||||
out('description: >-');
|
||||
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
|
||||
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
|
||||
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
|
||||
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
|
||||
out('version: 1');
|
||||
out('---');
|
||||
out();
|
||||
out('<!--');
|
||||
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
|
||||
out(' differs from what the generator produces, and an edit here is lost on the next run.');
|
||||
out();
|
||||
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
|
||||
out('-->');
|
||||
out();
|
||||
out('# What Officer is');
|
||||
out();
|
||||
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
|
||||
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
|
||||
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
|
||||
out('user, and **absence denies** — there is no row meaning "no".');
|
||||
out();
|
||||
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
|
||||
out('no, and *not shareable at all*.');
|
||||
out();
|
||||
out('# Permissions');
|
||||
out();
|
||||
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
|
||||
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
|
||||
out('diligence.');
|
||||
out();
|
||||
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
|
||||
const entries = byKind.get(kind) ?? [];
|
||||
if (!entries.length) continue;
|
||||
out(`## \`${kind}\` — ${meaning}`);
|
||||
out();
|
||||
for (const p of entries) {
|
||||
const bits: string[] = [];
|
||||
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
|
||||
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
|
||||
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
|
||||
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
|
||||
if (bits.length) out(` - ${bits.join(' · ')}`);
|
||||
if (p.selfService?.length) {
|
||||
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
|
||||
}
|
||||
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
|
||||
if (p.readOnlyWrites?.length) {
|
||||
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
|
||||
}
|
||||
}
|
||||
out();
|
||||
}
|
||||
|
||||
out('# Keyboard shortcuts');
|
||||
out();
|
||||
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||
out('list rather than remembering one.');
|
||||
out();
|
||||
for (const section of SHORTCUT_SECTIONS) {
|
||||
out(`## ${section.title}`);
|
||||
if (section.note) {
|
||||
out();
|
||||
out(section.note);
|
||||
}
|
||||
out();
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
|
||||
out();
|
||||
}
|
||||
|
||||
out('# What is NOT in here');
|
||||
out();
|
||||
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
|
||||
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
|
||||
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
|
||||
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
|
||||
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
|
||||
out(' commander is, how one is started, and how to help somebody shape one.');
|
||||
out();
|
||||
out('# When you are about to guess');
|
||||
out();
|
||||
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
|
||||
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
|
||||
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
|
||||
out('certain as one quoting a true one.');
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const body = await renderPlatformSkill();
|
||||
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
|
||||
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
|
||||
if (existing === body) {
|
||||
console.log('skills:platform — already current');
|
||||
} else {
|
||||
writeFileSync(SKILL_PATH, body);
|
||||
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export {
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
recordCommandHqTurn,
|
||||
ingestSpawnedChildren,
|
||||
recordSpawnedOutcome,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
listCommandHqWatcherFires,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
commandHqTags,
|
||||
commandHqTurns,
|
||||
commandHqWatcherFires,
|
||||
commandHqSpawned,
|
||||
} from './schema';
|
||||
|
||||
// ── Categories ──
|
||||
@@ -405,3 +406,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num
|
||||
.delete(commandHqSessionTags)
|
||||
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
/*
|
||||
Spawned children. The file is the inbox; these rows are the record — see the schema note.
|
||||
*/
|
||||
|
||||
export type SpawnedChild = {
|
||||
spawnedSessionId: string;
|
||||
firstSeenAt: string;
|
||||
finishedAt: string | null;
|
||||
totalUsd: number | null;
|
||||
outcome: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ingest ids the loop appended, idempotently, and return the commander's children.
|
||||
*
|
||||
* One statement for the insert because the loop can repeat itself after a restart, so a duplicate append is
|
||||
* expected rather than exceptional. `onConflictDoNothing` on the unique index makes re-ingesting the whole
|
||||
* file cost nothing and change nothing, which is what lets the caller ingest on every read instead of
|
||||
* running a cron that can silently stop.
|
||||
*/
|
||||
export async function ingestSpawnedChildren(sessionRowId: number, ids: string[]): Promise<SpawnedChild[]> {
|
||||
if (ids.length) {
|
||||
await db
|
||||
.insert(commandHqSpawned)
|
||||
.values(ids.map((spawnedSessionId) => ({ sessionRowId, spawnedSessionId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(commandHqSpawned)
|
||||
.where(eq(commandHqSpawned.sessionRowId, sessionRowId))
|
||||
.orderBy(commandHqSpawned.firstSeenAt);
|
||||
return rows.map((r) => ({
|
||||
spawnedSessionId: r.spawnedSessionId,
|
||||
firstSeenAt: r.firstSeenAt.toISOString(),
|
||||
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||
totalUsd: r.totalUsd,
|
||||
outcome: r.outcome,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a child cost, once something knows.
|
||||
*
|
||||
* Separate from the ingest because the two facts arrive at different times from different parties: the loop
|
||||
* knows the id at spawn, and only the child knows what it cost when it exits. Null cost stays null — an
|
||||
* unknown total must not be reported as zero.
|
||||
*/
|
||||
export async function recordSpawnedOutcome(
|
||||
sessionRowId: number,
|
||||
spawnedSessionId: string,
|
||||
outcome: { totalUsd?: number | null; outcome?: string | null },
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(commandHqSpawned)
|
||||
.set({
|
||||
finishedAt: new Date(),
|
||||
...(outcome.totalUsd === undefined ? {} : { totalUsd: outcome.totalUsd }),
|
||||
...(outcome.outcome === undefined ? {} : { outcome: outcome.outcome }),
|
||||
})
|
||||
.where(
|
||||
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,3 +195,53 @@ export const commandHqSessionTags = pgTable(
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per agent a commander spawned.
|
||||
*
|
||||
* ── Why the database, when the loop writes a file ──
|
||||
*
|
||||
* The file is an INBOX, not the record. A commander's loop is bash: it can append a session id with a shell
|
||||
* redirect and it cannot reach Postgres, and giving it a credential so it could would put an authenticated
|
||||
* writer inside a background job — worse than the problem. So the loop appends, and the platform ingests
|
||||
* into this table whenever anything asks. The file is allowed to be lossy; this is not.
|
||||
*
|
||||
* What the table buys that the file cannot:
|
||||
*
|
||||
* - the LINK, enforced. `onDelete: 'cascade'` means deleting a commander cannot leave orphaned children
|
||||
* behind, which is the whole reason this became important.
|
||||
* - queries. Cost per commander, how many agents a trigger spawned, which ones are still running — none of
|
||||
* which a newline-delimited file can answer without reading every one.
|
||||
* - a place for what the loop cannot know at append time: when the child finished, what it cost, whether
|
||||
* it succeeded. Those arrive later, from the child itself, and a file appended by a shell cannot be
|
||||
* updated in place.
|
||||
*
|
||||
* ── Why the child's own id is the key and not a serial ──
|
||||
*
|
||||
* Because the same child must not be ingested twice — the file is appended to by a loop that may repeat
|
||||
* itself after a restart, and the ingest is idempotent on `(session_row_id, spawned_session_id)`.
|
||||
*/
|
||||
export const commandHqSpawned = pgTable(
|
||||
'command_hq_spawned',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The commander. Cascades: its children go with it, which is the point. */
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
/** The child's own transcript id, as the harness wrote it. */
|
||||
spawnedSessionId: text('spawned_session_id').notNull(),
|
||||
/** When the platform first saw it in the inbox — not when the child started, which nothing records. */
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/**
|
||||
* Filled in later, by whoever finds out. Null means unknown rather than zero — a commander whose
|
||||
* children's costs are unknown must not display a confident total that happens to be too low, which is
|
||||
* the failure this table exists to end.
|
||||
*/
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
totalUsd: doublePrecision('total_usd'),
|
||||
outcome: text('outcome'),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
(table) => [uniqueIndex('uq_command_hq_spawned').on(table.sessionRowId, table.spawnedSessionId)],
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
listAllClaudeSessions,
|
||||
withCommanderFlag,
|
||||
loadClaudeSession,
|
||||
loadClaudeSessionById,
|
||||
deleteClaudeSession,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
stopBackgroundTask,
|
||||
claudeSessionContext,
|
||||
liveSessionTitle,
|
||||
isCommandersDir,
|
||||
} from './claude-sessions';
|
||||
import {
|
||||
listOpenCodeSessions,
|
||||
@@ -129,10 +129,15 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
// the row renderer) is indifferent to which directories the rows came from.
|
||||
const all = ctx.req.query('all') === '1';
|
||||
|
||||
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({
|
||||
...s,
|
||||
harness: 'claude' as const,
|
||||
}));
|
||||
// Which of these are commanders comes from the ROWS, not from where the transcript lives — a commander
|
||||
// chooses its own working directory now, so the directory answers nothing. One query for the whole set.
|
||||
const commanderIds = await listCommandHqSessionIds(user.id);
|
||||
const claude = withCommanderFlag(all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd), commanderIds).map(
|
||||
(s) => ({
|
||||
...s,
|
||||
harness: 'claude' as const,
|
||||
}),
|
||||
);
|
||||
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
|
||||
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
|
||||
//
|
||||
@@ -266,7 +271,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: isCommandersDir(who.email, detail.cwd),
|
||||
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,6 +297,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
sidecar.listLiveClaudeSessions(user.id),
|
||||
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
||||
]);
|
||||
const commanderIds = await listCommandHqSessionIds(user.id);
|
||||
const sessions = live.map((session) => {
|
||||
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
||||
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||
@@ -304,14 +310,16 @@ chatRouter.get('/live', async (ctx) => {
|
||||
harness: 'claude' as const,
|
||||
title: resolved?.title ?? null,
|
||||
cwd,
|
||||
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
|
||||
// history listing does it: every commander shares one directory, so the directory IS the answer and
|
||||
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
|
||||
// From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
|
||||
// directory therefore answers nothing. One query for the whole set, before the map below.
|
||||
//
|
||||
// A commander mid-first-turn has no row yet and so reports false here for one turn. That is the same
|
||||
// turn on which it must not arm anything, and it corrects itself the moment it is filed.
|
||||
//
|
||||
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
|
||||
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
|
||||
// deploy commander turning up in that swipe order is exactly what it must not do.
|
||||
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
|
||||
isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -984,20 +984,42 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||
return applyLineage(sessions);
|
||||
}
|
||||
|
||||
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
||||
/**
|
||||
* Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain.
|
||||
*
|
||||
* **Says nothing about commanders**, deliberately. It used to: every commander shared one directory, so the
|
||||
* directory was the answer and this computed it once per group. That stopped being true the moment a
|
||||
* commander could choose its own working directory — a commander in the platform checkout would have been
|
||||
* reported as an ordinary chat, and an ordinary chat in the old commanders directory as a commander.
|
||||
*
|
||||
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
|
||||
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
|
||||
* directory scan — see `withCommanderFlag`.
|
||||
*/
|
||||
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
||||
// Every commander a user has lives in ONE directory, so this is a property of the group rather than of
|
||||
// each transcript — computed once here instead of per row. See `isCommander` on the summary type for
|
||||
// why the flag has to reach the payload at all.
|
||||
const commander = isCommandersDir(who.email, cwd);
|
||||
return mergeChains(scanGroup(who, cwd))
|
||||
.map(publish)
|
||||
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this directory the one every commander shares?
|
||||
* Mark which of these are commanders, from the rows rather than from where they live.
|
||||
*
|
||||
* One query per request rather than one per session: `listCommandHqSessionIds` returns the whole set for an
|
||||
* account, and a set membership test is what the per-row decoration needs. Commanders are few, so the set is
|
||||
* small; the alternative — a lookup per transcript — would turn one list into fifty queries.
|
||||
*/
|
||||
export function withCommanderFlag<T extends { id: string }>(rows: T[], commanderIds: ReadonlySet<string>): T[] {
|
||||
return rows.map((row) => (commanderIds.has(row.id) ? { ...row, isCommander: true } : row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this the default commanders directory?
|
||||
*
|
||||
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
|
||||
* a commander now chooses its own working directory. This remains only for the places that legitimately care
|
||||
* about that specific path: creating it for a new OS account, and as the historical location of commanders
|
||||
* created before the cwd was choosable.
|
||||
*
|
||||
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
|
||||
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getCommandHqSession, getUserById } from 'officerdb';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
What a commander is told about ITSELF, once per session.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
|
||||
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
|
||||
background-job dialog composes. Which was fine while the only way to arm a commander was through that
|
||||
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
|
||||
starting its own background job never sees that preamble, so it would record nothing, and delete would go
|
||||
back to leaving orphaned loops without anybody noticing it had.
|
||||
|
||||
── Why the paths come from here and the method comes from the skill ──
|
||||
|
||||
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
|
||||
material that belongs with everything else about commanders, in the skill, where it can be as long as it
|
||||
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
|
||||
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
|
||||
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
|
||||
exactly what an always-loaded block is for.
|
||||
|
||||
Short for the same reason every affordance is short: it is charged to every turn of every commander.
|
||||
*/
|
||||
|
||||
type CommanderBlockParams = {
|
||||
/** The Officer account, for the row lookup and for the paths. */
|
||||
userId: number;
|
||||
/** Claude's transcript id — what a commander is keyed on everywhere. */
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
|
||||
if (!params.sessionId) return null;
|
||||
|
||||
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
|
||||
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
|
||||
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
|
||||
if (!row) return null;
|
||||
|
||||
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
|
||||
// session spawn, alongside the affordances and the skills index, which are also read here rather than
|
||||
// cached — the same trade for the same reason.
|
||||
const user = await getUserById(params.userId).catch(() => null);
|
||||
if (!user?.email) return null;
|
||||
const email = user.email;
|
||||
|
||||
return [
|
||||
'# You are a commander',
|
||||
'',
|
||||
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
|
||||
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
|
||||
'establish with the owner first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
|
||||
'',
|
||||
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
|
||||
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
|
||||
'',
|
||||
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves your loop running.',
|
||||
'',
|
||||
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
|
||||
'that is what their absence means. Ask, and arm once it is agreed.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -5,7 +5,14 @@ import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { getChatEventsSince } from 'officerdb';
|
||||
import { getChatEventsSince, ingestSpawnedChildren } from 'officerdb';
|
||||
import {
|
||||
clearSpawnedRecord,
|
||||
listSpawnedSessions,
|
||||
loopPidPath,
|
||||
spawnedRecordPath,
|
||||
stopCommanderProcesses,
|
||||
} from './spawned';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
@@ -469,11 +476,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
||||
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
||||
*
|
||||
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
|
||||
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to
|
||||
* update once this returns, and the running background process (if any) dies on its own the same way it
|
||||
* would if the harness process were killed out from under any other conversation — not a new failure
|
||||
* mode this route has to account for.
|
||||
* ── It cascades, and it has to ──
|
||||
*
|
||||
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
|
||||
*
|
||||
* 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
|
||||
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
|
||||
* process takes all of it. The comment here used to claim the background process "dies on its own" —
|
||||
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
|
||||
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
|
||||
* row left to disarm.
|
||||
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
|
||||
* — see `spawned.ts` for the three approaches that do not work.
|
||||
* 3. DELETE the commander's own transcript and row, as before.
|
||||
*
|
||||
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
|
||||
* could spawn a new one between steps 2 and 3.
|
||||
*
|
||||
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
|
||||
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
|
||||
*/
|
||||
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -483,9 +504,38 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
// 1. Everything this commander is still running.
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
|
||||
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||
// not stop the rest from going, and a child is often gone before its parent.
|
||||
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
||||
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
||||
(c) => c.spawnedSessionId,
|
||||
);
|
||||
let childrenDeleted = 0;
|
||||
for (const child of children) {
|
||||
try {
|
||||
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
|
||||
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
|
||||
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
|
||||
// transcript wherever it actually is, which is what makes this work for a child that ran in the
|
||||
// platform checkout rather than the commanders directory.
|
||||
if (deleteClaudeSession(who, getCommandersDir(who.email), child)) childrenDeleted += 1;
|
||||
} catch {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
|
||||
// 3. The commander itself.
|
||||
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
||||
await deleteCommandHqSession(user.id, sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
|
||||
// make it auditable after the fact.
|
||||
return ctx.json({ ok: true, processesStopped: stopped, childrenFound: children.length, childrenDeleted });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -518,6 +568,59 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/children
|
||||
*
|
||||
* The agents this commander spawned, ingested from the loop's inbox on the way through.
|
||||
*
|
||||
* Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop
|
||||
* without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on
|
||||
* the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the
|
||||
* UI likes.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
||||
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
||||
// this exists.
|
||||
const known = children.filter((c) => c.totalUsd !== null);
|
||||
return ctx.json({
|
||||
children,
|
||||
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
|
||||
unknownCost: children.length - known.length,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
*
|
||||
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||
*
|
||||
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
||||
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderStateDir } from '../../data-path';
|
||||
import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
/*
|
||||
What a commander spawned, and why the platform cannot work it out for itself.
|
||||
|
||||
A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself
|
||||
started. The platform never sees that spawn: it happens inside a background job, two processes below a
|
||||
conversation, and the agent talks to Anthropic rather than to us.
|
||||
|
||||
Three ways to recover the link were tried and none works:
|
||||
|
||||
- the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing
|
||||
identifying in its environment. Verified 2026-08-22.
|
||||
- the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look
|
||||
like markers the platform injects and are the harness's own, appearing in every session equally. A
|
||||
discriminator built on them rejected a real child and reported zero when one was running.
|
||||
- CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches
|
||||
as well as the agent following it.
|
||||
|
||||
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||
|
||||
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||
|
||||
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||
every link is required.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
const PID_FILE = 'loop-pid';
|
||||
|
||||
/** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every agent this commander recorded, oldest first, de-duplicated.
|
||||
*
|
||||
* Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a
|
||||
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||
*/
|
||||
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||
const path = spawnedRecordPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
|
||||
const id = raw.trim();
|
||||
if (SESSION_ID.test(id)) seen.add(id.toLowerCase());
|
||||
}
|
||||
return [...seen];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one, idempotently. For the platform's own use — the loop appends directly, by design.
|
||||
*
|
||||
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||
* that arrives it must write the same file rather than a second record.
|
||||
*/
|
||||
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||
} catch {
|
||||
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||
// without a commander to read it for.
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
── Stopping what a commander is running ──
|
||||
|
||||
The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and
|
||||
a `claude` process cannot be attributed to a particular commander from outside — every commander's session
|
||||
runs with the same cwd, so the process table cannot tell them apart.
|
||||
|
||||
So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the
|
||||
only party that knows. From that one number the whole tree follows — the loop's children include any agent
|
||||
it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct
|
||||
child, so a running grandchild is left orphaned and keeps going").
|
||||
|
||||
A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed
|
||||
before this existed, and pretending otherwise would report a clean delete over a loop still polling.
|
||||
*/
|
||||
|
||||
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||
const path = loopPidPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
try {
|
||||
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||
const pid = liveLoopPid(email, commanderSessionId);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
let signalled = 0;
|
||||
for (const target of tree) {
|
||||
try {
|
||||
process.kill(target, 'SIGTERM');
|
||||
signalled += 1;
|
||||
} catch {
|
||||
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
return signalled;
|
||||
}
|
||||
@@ -219,6 +219,21 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
*/
|
||||
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
||||
|
||||
/**
|
||||
* Where a commander records what it spawned — one directory per commander, named by its session id.
|
||||
*
|
||||
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
|
||||
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
|
||||
* commander started, because nothing else CAN know them — a spawned agent's id is not recoverable from its
|
||||
* process (no open descriptor, nothing in its environment) and its transcript is indistinguishable from any
|
||||
* other session's.
|
||||
*
|
||||
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
|
||||
* completely, its children cannot be followed, and its cost cannot be totalled.
|
||||
*/
|
||||
export const getCommanderStateDir = (email: string, sessionId: string) =>
|
||||
join(DATA_PATH, email, 'commander-state', sessionId);
|
||||
|
||||
/**
|
||||
* Which mail account's store to use, found on DISK rather than by asking the database.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anth
|
||||
import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
||||
import { affordanceBlock } from '../../affordances/load';
|
||||
import { skillsBlock } from '../../skills/load';
|
||||
import { commanderBlock } from '../../api/command-hq/commander-block';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
|
||||
@@ -540,6 +541,11 @@ function createSession(
|
||||
params: ClaudeSpawnStreamingParams,
|
||||
onEvent: (event: ChatEvent) => void,
|
||||
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
|
||||
/**
|
||||
* The commander block, or null. Passed IN because building it needs two database reads and this function
|
||||
* is synchronous — hoisted to `spawnClaudeStreaming`, which is async and already has the account.
|
||||
*/
|
||||
commander: string | null,
|
||||
): PersistentSession {
|
||||
const { sessionKey } = params;
|
||||
const input = makeInputQueue();
|
||||
@@ -608,7 +614,15 @@ function createSession(
|
||||
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
|
||||
// written an hour ago should be offered on the next session.
|
||||
const skills = skillsBlock();
|
||||
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
|
||||
/*
|
||||
And, for a commander, the two paths only this session can be told.
|
||||
|
||||
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
|
||||
preamble, which is where this used to live and which an agent arming itself never sees. See
|
||||
`api/command-hq/commander-block.ts`.
|
||||
*/
|
||||
// `commander` is a parameter, not computed here — see the note on it.
|
||||
const preamble = [affordances, skills, commander].filter(Boolean).join('\n\n') || null;
|
||||
|
||||
const q = query({
|
||||
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
||||
@@ -880,7 +894,16 @@ export async function spawnClaudeStreaming(
|
||||
await applyModelChange(session, params.model);
|
||||
await applyPlanModeChange(session, params.planMode);
|
||||
} else {
|
||||
session = createSession(params, onEvent, onNotify);
|
||||
/*
|
||||
The commander block is built here, before the session exists, because it needs the database and
|
||||
`createSession` is synchronous. It resolves the row for this transcript id and returns null for
|
||||
anything that is not a commander — which is every ordinary chat, at the cost of one indexed lookup.
|
||||
*/
|
||||
const commander = await commanderBlock({
|
||||
userId: params.userId,
|
||||
sessionId: getClaudeSession(params.sessionKey, params.userId) ?? params.resumeSessionId ?? null,
|
||||
});
|
||||
session = createSession(params, onEvent, onNotify, commander);
|
||||
}
|
||||
session.pushTurn(params.prompt, params.images);
|
||||
}
|
||||
|
||||
@@ -622,6 +622,13 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
would lose the brand-new composer, which has no session yet.
|
||||
*/
|
||||
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||
/*
|
||||
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
|
||||
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
|
||||
conversation and is not interchangeable; using it here would key a commander's record on an id nothing
|
||||
else knows, which is the bug that made a working commander render as an empty pane.
|
||||
*/
|
||||
claudeSessionId: chat.claudeSessionId,
|
||||
messages,
|
||||
historicalCount,
|
||||
streamingText,
|
||||
|
||||
@@ -32,6 +32,14 @@ export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
|
||||
export type BackgroundJobSpec = {
|
||||
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
|
||||
mode: 'command' | 'prompt';
|
||||
/**
|
||||
* Where a COMMANDER's loop must record itself. Absent for an ordinary chat's background job.
|
||||
*
|
||||
* Two paths, both handed over rather than guessed, because the loop cannot derive either: it does not know
|
||||
* which commander it belongs to, and nothing outside it knows its pid or what it spawns. Without this
|
||||
* record a commander cannot be stopped or fully deleted — see `api/command-hq/spawned.ts`.
|
||||
*/
|
||||
record?: { loopPidPath: string; spawnedPath: string };
|
||||
/** The literal command, or the intent. */
|
||||
body: string;
|
||||
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
|
||||
@@ -141,9 +149,40 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
A commander's loop must make itself findable. Nothing else can: the platform has no session-to-process
|
||||
map, a spawned agent's id is not recoverable from its process or its transcript, and the loop is the only
|
||||
party that knows either. Without these two lines a commander cannot be stopped or deleted completely —
|
||||
its loop outlives it, still polling and still able to spawn.
|
||||
|
||||
Numbered after whatever came before, like the rules above, and only present when a record path was
|
||||
supplied — an ordinary chat's background job has no commander to belong to.
|
||||
*/
|
||||
// The number the NEXT rule would have taken, so nothing lands on a fraction and the output rule below
|
||||
// simply moves down one when this is present.
|
||||
const nextRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||
if (spec.record) {
|
||||
rules.push(
|
||||
``,
|
||||
`${nextRuleNumber}. RECORD YOURSELF, first thing, before the wait begins:`,
|
||||
``,
|
||||
` mkdir -p "$(dirname '${spec.record.loopPidPath}')"`,
|
||||
` echo $$ > '${spec.record.loopPidPath}'`,
|
||||
``,
|
||||
` And every time you spawn an agent, append its session id — the \`session_id\` on the`,
|
||||
` \`{"type":"system","subtype":"init"}\` event, which you are already parsing — one per line:`,
|
||||
``,
|
||||
` echo "<session_id>" >> '${spec.record.spawnedPath}'`,
|
||||
``,
|
||||
` This is not bookkeeping. It is the only record of what you are and what you started: the`,
|
||||
` platform cannot find your process, and a spawned agent's id is unrecoverable once it exits.`,
|
||||
` Without it, deleting this commander leaves you running.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
|
||||
// which number it lands on, matching whichever rules already came before it in this particular prompt.
|
||||
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||
const outputRuleNumber = nextRuleNumber + (spec.record ? 1 : 0);
|
||||
rules.push(
|
||||
``,
|
||||
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
|
||||
|
||||
@@ -35,6 +35,13 @@ type BackgroundJobDialogProps = {
|
||||
cwd: string;
|
||||
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
|
||||
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
|
||||
/**
|
||||
* Where a commander's loop must record its pid and what it spawns. Absent for any other chat.
|
||||
*
|
||||
* Passed in rather than derived here: the paths are rooted in the platform's data directory and keyed on
|
||||
* the commander's session id, neither of which a dialog knows.
|
||||
*/
|
||||
recordPaths?: { loopPidPath: string; spawnedPath: string };
|
||||
/**
|
||||
* Is this composer running inside a Command HQ commander?
|
||||
*
|
||||
@@ -55,6 +62,7 @@ export const BackgroundJobDialog = ({
|
||||
cwd,
|
||||
onSubmit,
|
||||
isCommander = false,
|
||||
recordPaths,
|
||||
}: BackgroundJobDialogProps) => {
|
||||
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
||||
const [body, setBody] = useState('');
|
||||
@@ -102,6 +110,10 @@ export const BackgroundJobDialog = ({
|
||||
onFire,
|
||||
duration: timed ? minutes : null,
|
||||
cwd,
|
||||
// Only a commander gets the record instruction: it is what makes stopping and deleting one possible,
|
||||
// and an ordinary chat's background job has no commander to belong to. `record` is undefined until the
|
||||
// session has an id, which is the same moment it becomes a commander — see NewCommander.
|
||||
...(recordPaths ? { record: recordPaths } : {}),
|
||||
};
|
||||
// The intent is what the transcript shows; the preamble rides underneath it.
|
||||
onSubmit(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useE
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
|
||||
import { armBackgroundJob } from '../../../background-job-origin';
|
||||
import { useWorkspace } from '../../../components/Workspace';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
@@ -19,6 +20,9 @@ type InputAreaProps = {
|
||||
|
||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -209,6 +213,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
server. Only the radio button was missing, so the feature looked unbuilt.
|
||||
*/
|
||||
isCommander={manager.isCommander}
|
||||
recordPaths={recordPaths}
|
||||
onSubmit={(intent, preamble, armWatcher) => {
|
||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
||||
// background jobs an agent starts on its own.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type RecordPaths = { loopPidPath: string; spawnedPath: string };
|
||||
|
||||
/**
|
||||
* Where this commander's loop must record its pid and what it spawns.
|
||||
*
|
||||
* Fetched rather than derived: both are rooted in `DATA_PATH`, a server fact. Cached indefinitely because
|
||||
* they are a pure function of the session id — nothing can change them but a different commander.
|
||||
*
|
||||
* Null for anything that is not a commander, and for a commander that has no id yet. The composer passes
|
||||
* whatever this returns straight into the background-job prompt, so absent means the loop is simply not
|
||||
* asked to record — correct for an ordinary chat, and the reason a commander must have its id before its
|
||||
* first background job.
|
||||
*/
|
||||
export function useCommanderRecordPaths(sessionId: string | null): RecordPaths | undefined {
|
||||
const client = useClient();
|
||||
const { data } = useQuery<RecordPaths>({
|
||||
queryKey: ['command-hq', 'record-paths', sessionId],
|
||||
queryFn: () => client.get<RecordPaths>(`/command-hq/commanders/${sessionId}/record-paths`),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { ChatMessage } from '../Chat/types';
|
||||
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
|
||||
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
|
||||
// read the route directly, per `docs/navigation-audit.md`):
|
||||
@@ -32,6 +33,37 @@ function EmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instructions shown in an empty new-commander pane.
|
||||
*
|
||||
* Deliberately plain and deliberately unfinished — see `newCommanderGuide.ts`. A `provisional` section
|
||||
* carries a visible marker so a guide that is still being written cannot be mistaken for one that is done,
|
||||
* which matters because this ships on every deploy whether the copy is ready or not.
|
||||
*/
|
||||
const NewCommanderGuide = () => (
|
||||
<div className="shrink-0 overflow-y-auto border-b border-border px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||
{NEW_COMMANDER_GUIDE.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
{section.title}
|
||||
{section.provisional && (
|
||||
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
|
||||
draft
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{section.body.map((paragraph) => (
|
||||
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
||||
*
|
||||
@@ -103,6 +135,9 @@ function NewCommander({
|
||||
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
|
||||
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
|
||||
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
|
||||
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
|
||||
message to start", which is true and tells a first-time user nothing about what to send. */}
|
||||
{chat.messages.length === 0 && <NewCommanderGuide />}
|
||||
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
What the pane says before a new commander has been spoken to.
|
||||
|
||||
── Why this is data, and why it is a skeleton ──
|
||||
|
||||
The creation flow is being rebuilt. Starting a commander currently means knowing to open the background-job
|
||||
dialog and arm a watcher from it, which is not discoverable and reads as configuration when it is really
|
||||
just "talk to it" — so the dialog stops being the way in, and this pane explains the way in instead.
|
||||
|
||||
The copy is NOT written yet, by agreement: the instructions have to describe a flow that does not exist
|
||||
yet, and writing them before the flow is decided produces text that is wrong in a way nobody notices.
|
||||
What is here is the structure plus the parts that are already TRUE regardless of what the flow becomes —
|
||||
what a commander is, and that it begins as an ordinary conversation.
|
||||
|
||||
Sections marked `provisional` render with a visible marker, so a half-written guide cannot ship looking
|
||||
finished. Removing the flag is the last step of writing one, not a formatting choice.
|
||||
*/
|
||||
|
||||
export type GuideSection = {
|
||||
title: string;
|
||||
/** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */
|
||||
body: string[];
|
||||
/** True until the copy is settled. Renders a marker; the whole point is that it is visible. */
|
||||
provisional?: boolean;
|
||||
};
|
||||
|
||||
export const NEW_COMMANDER_GUIDE: GuideSection[] = [
|
||||
{
|
||||
title: 'What a commander is',
|
||||
body: [
|
||||
'An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.',
|
||||
'It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Starting one',
|
||||
provisional: true,
|
||||
body: [
|
||||
'Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.',
|
||||
'TODO: the concrete steps, once the creation flow is settled. This replaces "open the background-job dialog and arm a watcher", which nobody could be expected to guess.',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what
|
||||
// the flow accepts, and the flow is being rebuilt. Written now, all three would need rewriting — and a
|
||||
// wrong example is worse than none, because a user copies it and blames themselves when it does not work.
|
||||
//
|
||||
// Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is
|
||||
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
|
||||
// the boundary is visible.
|
||||
title: 'Examples of a first message',
|
||||
provisional: true,
|
||||
body: [
|
||||
'TODO: two or three example opening prompts, once the creation flow is settled. They are what turns "describe the job" from an instruction into something a first-time user can act on.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'While it runs',
|
||||
provisional: true,
|
||||
body: [
|
||||
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
@@ -200,6 +201,26 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/*
|
||||
Tell the session LISTS that this conversation exists.
|
||||
|
||||
The chat header resolves its own title by finding this session in the cached list —
|
||||
`sessions.find((s) => s.id === sessionId)`. A newborn conversation is not in that list: it has
|
||||
`staleTime: 30s` and nothing invalidated it, so for up to half a minute the header read "New chat" and
|
||||
the rename control was absent, because rename is a per-row action on a list the session was not in yet.
|
||||
Navigating away and back fixed it, which is what made it look like a rendering bug rather than a stale
|
||||
cache.
|
||||
|
||||
Fired when the transcript id arrives — the START of the turn, not the end — because that is the moment
|
||||
the session becomes real on disk and the server can already name it from the first user message. Fired
|
||||
again when a turn finishes, because the harness writes its own `ai-title` while the turn runs.
|
||||
*/
|
||||
const announceSession = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['CLAUDE_SESSIONS'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
|
||||
}, [queryClient]);
|
||||
|
||||
// Fetch the next older window and prepend it. The scroll container restores its position from the
|
||||
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
|
||||
@@ -343,6 +364,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
claudeSessionIdRef.current = msg.claudeSessionId;
|
||||
setClaudeSessionId(msg.claudeSessionId);
|
||||
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
||||
// The lists do not know this conversation exists yet — see `announceSession`.
|
||||
announceSession();
|
||||
break;
|
||||
|
||||
case 'assistant:delta':
|
||||
@@ -420,6 +443,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
const hadTools = toolCallsInTurnRef.current;
|
||||
toolCallsInTurnRef.current = false;
|
||||
onTurnCompleteRef.current?.(hadTools);
|
||||
// Every turn end: the harness writes its own `ai-title` while a turn runs, so the name the lists are
|
||||
// showing may now be bettered by the one on disk.
|
||||
announceSession();
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: waiting-on-owner/review-the-officer-secret-store-built-last
|
||||
title: "Review officer-secret-store, built overnight and untested by the owner"
|
||||
theme: waiting-on-owner
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**Review `officer-secret-store`, built overnight and untested by the owner.** Filed 2026-08-22 at his
|
||||
request: it was implemented last night, he has not had time to look at it, and it is waiting on him
|
||||
rather than on more work.
|
||||
|
||||
**It is NOT part of the platform and must not be merged into it.** A separate project, in a separate
|
||||
repository, informed by what the platform does but deliberately its own thing:
|
||||
|
||||
- **Working tree:** `/home/pastilhas/officer-dev/officer-secret-store`
|
||||
- **Remote:** `git@gitea.officer.dev:officerdev/secret-store.git`
|
||||
- Five commits, 16 TypeScript files across `src/` and `client/`, seven documents in `docs/`, two test
|
||||
files (`access.test.ts`, `crypto.test.ts`).
|
||||
|
||||
What it claims to be, from its own README: a secret store for a tailnet, where apps fetch credentials
|
||||
over HTTP **holding no credential to do it** — the caller is identified by asking the local tailscaled
|
||||
which node is at the source address, so nothing the client sends is consulted and a client cannot lie
|
||||
about who it is. Access is a grant joining an app id to a node, and absence denies. It has **no npm
|
||||
packages at runtime**, on the argument that a store defending against dependency-chain attacks cannot
|
||||
have a dependency chain.
|
||||
|
||||
Its own status line: *"working, tested end to end on a live tailnet, not yet running anything real."*
|
||||
One commit records a live cross-machine check — `alpha` knew the app id and was refused — which is the
|
||||
right shape of test for the claim above.
|
||||
|
||||
Where to start, per the README: `docs/authentication.md`, which explains why the original design
|
||||
(authenticating with the app id alone) cannot work. That is the reasoning the whole thing turns on, so
|
||||
it is the part most worth a second pair of eyes. `docs/threat-model.md` and `docs/decisions.md` are the
|
||||
other two that carry judgement rather than description.
|
||||
|
||||
Related but distinct: the platform's own `docs/secret-store.md` is a **design, not built** — moving the
|
||||
encryption and signing keys out of `.env` into a SQLite store on this machine. Whether that design is
|
||||
now answered by this project, or stays separate, is a question for the review rather than an assumption
|
||||
either way.
|
||||
Reference in New Issue
Block a user