@staix/agent-hub 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -6
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +17 -3
  5. package/docs/agent-notes/budget.md +4 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +14 -9
  8. package/docs/agent-notes/tasks.md +9 -4
  9. package/docs/agent-notes/tests.md +3 -3
  10. package/docs/dogfood.md +9 -4
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +90 -18
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +17 -0
  16. package/docs/security.md +37 -10
  17. package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
  18. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  19. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  20. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  21. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  22. package/docs/verified.json +29 -17
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +992 -312
  26. package/src/adapters/acp.ts +43 -7
  27. package/src/adapters/claude-channel.ts +26 -4
  28. package/src/adapters/claude-implementer.ts +243 -0
  29. package/src/adapters/claude-print-child.ts +90 -0
  30. package/src/adapters/claude-task-sessions.ts +80 -0
  31. package/src/adapters/compat.ts +4 -0
  32. package/src/adapters/conformance/enrollment.ts +2 -1
  33. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  34. package/src/adapters/definitions/builtin-metadata.ts +14 -2
  35. package/src/adapters/definitions/native-channel-state.ts +1 -1
  36. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  37. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  38. package/src/adapters/definitions/native-factories.ts +8 -0
  39. package/src/adapters/kimi-wire-usage.ts +303 -0
  40. package/src/adapters/registry-types.ts +7 -3
  41. package/src/adapters/registry.ts +14 -2
  42. package/src/adapters/reviewer-print.ts +30 -53
  43. package/src/cli/console-config.ts +123 -0
  44. package/src/cli/console-state.ts +28 -1
  45. package/src/cli/console.ts +25 -1
  46. package/src/cli/facts-hook.ts +9 -5
  47. package/src/cli/help.ts +4 -3
  48. package/src/cli/launch.ts +10 -4
  49. package/src/cli/main.ts +47 -9
  50. package/src/cli/output.ts +28 -0
  51. package/src/cli/reset.ts +1 -1
  52. package/src/cli/settings-edit.ts +89 -0
  53. package/src/cli/terminal-recovery.ts +1 -6
  54. package/src/hub/action-enforce.ts +82 -0
  55. package/src/hub/action-policy.ts +22 -10
  56. package/src/hub/board.ts +26 -2
  57. package/src/hub/budget.ts +5 -0
  58. package/src/hub/bus.ts +5 -5
  59. package/src/hub/claude-launch-proof.ts +33 -0
  60. package/src/hub/config-apply.ts +5 -0
  61. package/src/hub/config-edit-host.ts +173 -0
  62. package/src/hub/config-edit.ts +282 -0
  63. package/src/hub/config-tier.ts +89 -0
  64. package/src/hub/control-client.ts +24 -3
  65. package/src/hub/daemon.ts +368 -59
  66. package/src/hub/deliverable-files.ts +131 -0
  67. package/src/hub/deliverables.ts +125 -0
  68. package/src/hub/delivery-journal.ts +18 -2
  69. package/src/hub/events.ts +3 -1
  70. package/src/hub/hub-tools.ts +19 -3
  71. package/src/hub/instances.ts +7 -4
  72. package/src/hub/kimi-usage.ts +144 -0
  73. package/src/hub/manager.ts +1 -1
  74. package/src/hub/peers.ts +7 -0
  75. package/src/hub/permission-payload.ts +15 -0
  76. package/src/hub/routing.ts +118 -3
  77. package/src/hub/settings.ts +1 -1
  78. package/src/hub/tasks.ts +202 -15
  79. package/src/hub/token-window.ts +33 -0
  80. package/src/hub/usage-rollup.ts +16 -6
  81. package/src/hub/workflow.ts +16 -0
  82. package/src/ui/index.html +115 -1
package/src/cli/main.ts CHANGED
@@ -18,6 +18,7 @@ import { matchesPolicy } from "../hub/path-policy.ts";
18
18
  import { projectedDiff } from "../hub/projected-diff.ts";
19
19
  import { TextProjections } from "../hub/text-projection.ts";
20
20
  import { terminalText } from "./console-state.ts";
21
+ import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
21
22
  import { petView, readPet, renderPet } from "./pet.ts";
22
23
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
23
24
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
@@ -36,7 +37,7 @@ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "
36
37
  import { randomUUID } from "node:crypto";
37
38
  import { homedir } from "node:os";
38
39
  import { basename, dirname, join, relative, resolve } from "node:path";
39
- import { ControlClient, readControl } from "../hub/control-client.ts";
40
+ import { retryLaunchConnect, ControlClient, readControl } from "../hub/control-client.ts";
40
41
  import { loadConfig } from "../hub/daemon.ts";
41
42
  import { factsHook } from "./facts-hook.ts";
42
43
  import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
@@ -89,7 +90,7 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
89
90
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
90
91
  import { runConsole } from "./console.ts";
91
92
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
92
- import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
93
+ import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
93
94
  import { renderHelp, configuredAcpHelp } from "./help.ts";
94
95
  import { renderTailEvent } from "./tail-render.ts";
95
96
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
@@ -274,13 +275,15 @@ function printOutput(lines: Span[][], color: boolean): void {
274
275
  }
275
276
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
276
277
  const lifecycle = { inspectProject, startProject, stopProject };
277
- const connect = async () => {
278
+ const connect = async (timeoutMs = 3000) => {
278
279
  assertDelegatedAuditConsumer();
279
280
  if (delegatedAuditHub && !delegatedConnectionTaken) { delegatedConnectionTaken = true; return delegatedAuditHub; }
280
281
  return ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd }
281
- : { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) });
282
+ : { role: "console", projectRoot: cwd, ...(delegatedAuditHub ? { instanceId: delegatedAuditHub.instanceId } : {}) }, timeoutMs);
282
283
  };
283
284
 
285
+ const connectLaunch = () => retryLaunchConnect(connect, () => console.error("hub busy, retrying"));
286
+
284
287
  function registeredProject(): Project {
285
288
  const registry = new Registry();
286
289
  try { return registry.register(cwd, stateDir); }
@@ -943,7 +946,7 @@ const commands: Record<string, () => Promise<void> | void> = {
943
946
  // Preserve the original guard before native effects; previews remain read-only and precede this guard.
944
947
  if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
945
948
  const nativeConnect = async () => {
946
- const hub = await connect();
949
+ const hub = await connectLaunch();
947
950
  try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
948
951
  catch (error) { hub.close(); throw error; }
949
952
  };
@@ -955,7 +958,10 @@ const commands: Record<string, () => Promise<void> | void> = {
955
958
  projectConfig, connect: nativeConnect, fail,
956
959
  exec: (bin, argv, tool) => exec(bin, argv, tool, context.executionCwd),
957
960
  execWithEnv: (bin, argv, env) => execWithEnv(bin, argv, env, selectedInstance ?? entry.command, context.executionCwd), takeFlags,
958
- helpers: { launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
961
+ helpers: { resolveExecutionCwd: peer => {
962
+ const current = readInstanceRoleConfig(cwd);
963
+ return resolveInstanceExecutionCwd(cwd, peer, current.instances, effectiveInstanceRoles(peer, current.instances, current.roles, current.catalog), current.catalog);
964
+ }, launcherPreview: (tool, raw, projectRoot, state, unattended) => launcherPreview(tool, raw, projectRoot, state, unattended, selectedInstance), readControl, recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, recoveryLock, processSignature,
959
965
  buildLaunch, claudeObservationHooks, recordClaudeLaunch, cleanupStaleClaudeSettings, cleanupClaudeSettings,
960
966
  nativeLaunchEnv, runClaudeLaunch, childEnv, qwenSessionId, configureJcodeMcp, configureKimiMcp,
961
967
  buildKimiTuiLaunch, startModeOf },
@@ -1141,7 +1147,15 @@ const commands: Record<string, () => Promise<void> | void> = {
1141
1147
 
1142
1148
  backlog: async () => {
1143
1149
  const [sub, id, revision, value, ...reason] = args;
1144
- if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
1150
+ if (sub === "list" && outputArguments().length === 1) {
1151
+ // #399: a table by default; --json prints the hub's listing unchanged, and a refusal is printed as it came.
1152
+ const options = outputOptions(), raw = await taskOp("hub_backlog_list", {});
1153
+ if (options.json) return console.log(raw);
1154
+ let cards: unknown;
1155
+ try { cards = JSON.parse(raw); } catch { return console.log(raw); }
1156
+ if (!Array.isArray(cards)) return console.log(raw);
1157
+ return printOutput(renderBacklog(cards, options.columns, Date.now(), options.full), options.color);
1158
+ }
1145
1159
  if (sub === "groom" && args.length === 1) return console.log(await taskOp("hub_planner_groom", {}));
1146
1160
  if (["return", "return-untouched"].includes(sub ?? "") && /^[1-9]\d*$/.test(id ?? "") && /^\d+$/.test(revision ?? "") && args.length >= 4) {
1147
1161
  return console.log(await taskOp("hub_backlog_return_untouched", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
@@ -1509,7 +1523,31 @@ const commands: Record<string, () => Promise<void> | void> = {
1509
1523
  } finally { hub.close(); }
1510
1524
  },
1511
1525
  settings: async () => {
1512
- const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload";
1526
+ // #689: structural config edits (instances, review, snapshots, approvals, context.gate, sdlc.toml) and their backups. The hub validates and writes.
1527
+ if (args[0] === "edit" || args[0] === "backups") {
1528
+ // A command line the parser refuses never reaches the hub, not even as a connection.
1529
+ const early = args[0] === "backups" ? (args.length > 2 || (args[1] !== undefined && args[1] !== "--json") ? SETTINGS_EDIT_USAGE : undefined) : (() => { const parsed = parseSettingsEditArgs(args.slice(1)); return typeof parsed === "string" ? parsed : undefined; })();
1530
+ if (early) fail(early);
1531
+ const hub = await connect();
1532
+ try {
1533
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1534
+ const usage = await runSettingsEdit(args, {
1535
+ request: async message => {
1536
+ const reply = await hub.request(message, 35_000);
1537
+ if (reply.ok !== true) {
1538
+ const error = String(reply.error ?? "config edit refused");
1539
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "config_edit/i.test(error) ? `${error}; upgrade and restart the running hub to use ahub settings edit` : error);
1540
+ }
1541
+ return reply;
1542
+ },
1543
+ ask: async prompt => { if (!rl) return null; try { return await rl.question(prompt); } catch { return null; } },
1544
+ print: line => console.log(terminalText(line)),
1545
+ }).finally(() => rl?.close());
1546
+ if (usage) fail(usage);
1547
+ } finally { hub.close(); }
1548
+ return;
1549
+ }
1550
+ const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload | edit <file> ... | backups";
1513
1551
  const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
1514
1552
  const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
1515
1553
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
@@ -2254,7 +2292,7 @@ async function runConductorCommand(): Promise<void> {
2254
2292
  const runConfigured = configuredCommand ? async () => {
2255
2293
  const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2256
2294
  if (!selection.preview) assertLifecycleAvailable();
2257
- await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2295
+ await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2258
2296
  } : undefined;
2259
2297
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2260
2298
  try {
package/src/cli/output.ts CHANGED
@@ -249,6 +249,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
249
249
  const actionPolicy = data.actionPolicyInfo?.[id];
250
250
  if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
251
251
  if (typeof p.reviewerUnavailable === "string" && p.reviewerUnavailable) out.push(...detail("reviewer", flat(p.reviewerUnavailable), columns, "failure")); // #722: the cause class, never a path
252
+ if (p.ownerIsolationNote) out.push(...detail("owner isolation", p.ownerIsolationNote, columns, "attention"));
252
253
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
253
254
  if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
254
255
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
@@ -279,6 +280,33 @@ export function stageCode(next: string): string {
279
280
  }
280
281
  const BOARD_LEGEND = "STAGE: [#---] SDLC progress, stage: next step. issue? no issue, PR? no PR, CI? checks, review? not reviewed, threads? open threads. Full advice: ahub board --full.";
281
282
  const ellipsis = (text: string, room: number) => width(text) <= room ? text : room <= 3 ? ".".repeat(Math.max(0, room)) : (() => { let out = ""; for (const unit of characters(text)) { if (width(out + unit) > room - 3) break; out += unit; } return out + "..."; })();
283
+ /**
284
+ * #399: `ahub backlog list` as a table: one line per card, by rank (unranked last) then id. With a width, TITLE goes last and is
285
+ * cut like the compact board, and AGE then CLASS give way first on a narrow terminal; without one (piped) or with full, titles are whole.
286
+ */
287
+ export function renderBacklog(cards: any[], columns?: number, now = Date.now(), full = false): Span[][] {
288
+ if (!cards.length) return [[span("no backlog cards")]];
289
+ const sorted = [...cards].sort((a, b) => (typeof a.rank === "number" ? a.rank : Infinity) - (typeof b.rank === "number" ? b.rank : Infinity) || a.id - b.id);
290
+ let names = ["RANK", "ID", "PRI", "RESERVED", "AFTER", "CLASS", "AGE", "TITLE"];
291
+ let table: OutputCell[][] = sorted.map(card => {
292
+ const deps: number[] = Array.isArray(card.deps) ? card.deps : [];
293
+ return [span(typeof card.rank === "number" ? card.rank : "-", "muted", true), span(`#${card.id}`, "info", true), span(typeof card.priority === "number" ? `P${card.priority}` : "-", undefined, true),
294
+ span(card.reserved ?? "-", peerTone(card.reserved ?? ""), true), span(deps.length ? (full || columns === undefined ? deps.map(id => `#${id}`).join(",") : `#${deps[0]}${deps.length > 1 ? `,+${deps.length - 1}` : ""}`) : "-", undefined, true), span(card.class ?? "-", undefined, true),
295
+ span(typeof card.created === "number" ? duration(now - card.created) : "-", "muted", true), span(flat(card.title))];
296
+ });
297
+ if (!full && columns !== undefined) {
298
+ const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
299
+ const room = () => columns - names.reduce((sum, name, i) => name === "TITLE" ? sum : sum + Math.max(width(name), ...table.map(row => width(flat(row[i]!.text)))), 0) - 2 * (names.length - 1);
300
+ const want = Math.min(30, Math.max(...table.map(row => width(row[row.length - 1]!.text))));
301
+ for (const name of ["AGE", "CLASS", "PRI"]) if (room() < want) drop(name);
302
+ const titleRoom = Math.max(5, room());
303
+ table = table.map(row => [...row.slice(0, -1), { ...row[row.length - 1]!, text: ellipsis(row[row.length - 1]!.text, titleRoom) }]);
304
+ }
305
+ const owners = new Map<string, number>();
306
+ for (const card of sorted) owners.set(card.reserved ?? "unreserved", (owners.get(card.reserved ?? "unreserved") ?? 0) + 1);
307
+ const summary = `${plural(sorted.length, "backlog card")}; reserved: ${[...owners].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([owner, n]) => `${owner} ${n}`).join(", ")}`;
308
+ return [...rows(names, table, columns), [], ...lines(summary, columns)];
309
+ }
282
310
  /** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
283
311
  function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
284
312
  const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
package/src/cli/reset.ts CHANGED
@@ -11,7 +11,7 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
11
11
  /** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
12
12
  export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
13
13
  /** The fixed pointers plus each Claude instance's own session and context files (#450 I3). */
14
- const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
14
+ const sessionPointers = (stateDir: string): string[] => [...SESSION_POINTERS, ...(existsSync(stateDir) ? readdirSync(stateDir).filter(name => /^claude-(session|context|task-sessions)\.[a-z][a-z0-9-]{0,31}\.json$/.test(name)) : [])];
15
15
  /** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
16
16
  export const MANIFEST = ["status.json", "control-token", "hub.pid"];
17
17
 
@@ -0,0 +1,89 @@
1
+ /** #689: `ahub settings edit` and `ahub settings backups`. Parsing and rendering only; the hub validates, previews and writes. */
2
+ export interface SettingsEditArgs { file: "config.local.json" | "sdlc.toml"; set: { path: string[]; value: unknown }[]; reason?: string; preview: boolean; yes: boolean }
3
+ export const SETTINGS_EDIT_USAGE = "usage: ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | ahub settings backups [--json]";
4
+
5
+ /** The request a command line asks for, or the usage line. Values are JSON, so a string needs its quotes. */
6
+ export function parseSettingsEditArgs(argv: readonly string[]): SettingsEditArgs | string {
7
+ const [file, ...rest] = argv;
8
+ if (file !== "config.local.json" && file !== "sdlc.toml") return SETTINGS_EDIT_USAGE;
9
+ const set: SettingsEditArgs["set"] = [];
10
+ let reason: string | undefined, preview = false, yes = false;
11
+ const path = (text: string | undefined): string[] | undefined => {
12
+ if (!text || text.startsWith(".") || text.endsWith(".") || text.includes("..")) return undefined;
13
+ return text.split(".");
14
+ };
15
+ for (let index = 0; index < rest.length; index++) {
16
+ const word = rest[index]!;
17
+ if (word === "--preview") preview = true;
18
+ else if (word === "--yes") yes = true;
19
+ else if (word === "--reason") { reason = rest[++index]; if (reason === undefined) return SETTINGS_EDIT_USAGE; }
20
+ else if (word === "set") {
21
+ const target = path(rest[++index]), text = rest[++index];
22
+ if (!target || text === undefined) return SETTINGS_EDIT_USAGE;
23
+ try { set.push({ path: target, value: JSON.parse(text) }); } catch { return SETTINGS_EDIT_USAGE; }
24
+ } else if (word === "remove") {
25
+ const target = path(rest[++index]);
26
+ if (!target) return SETTINGS_EDIT_USAGE;
27
+ set.push({ path: target, value: null });
28
+ } else return SETTINGS_EDIT_USAGE;
29
+ }
30
+ if (!set.length) return SETTINGS_EDIT_USAGE;
31
+ return { file, set, ...(reason !== undefined ? { reason } : {}), preview, yes };
32
+ }
33
+
34
+ /** What a person reads before confirming: the diff lines, the warning of a tier 3 key and the word to type. */
35
+ export function renderEditPreview(reply: { ok?: unknown; lines?: unknown; warning?: unknown; confirm?: unknown }): string[] {
36
+ const lines = Array.isArray(reply.lines) ? reply.lines.map(line => String(line)) : [];
37
+ if (typeof reply.warning === "string") lines.push(`warning: ${reply.warning}`);
38
+ if (typeof reply.confirm === "string") lines.push(`type ${reply.confirm} to confirm`);
39
+ return lines;
40
+ }
41
+
42
+ export interface SettingsEditPorts {
43
+ request: (message: Record<string, unknown>) => Promise<Record<string, unknown>>;
44
+ /** Asks one line on a terminal; null when there is none. */
45
+ ask: (prompt: string) => Promise<string | null>;
46
+ print: (line: string) => void;
47
+ }
48
+ /**
49
+ * The whole command, with its ports injected. The person's terminal is the only place a tier 3 key can be changed, and only by
50
+ * typing the key's name: `--yes` confirms an ordinary structural edit and never a tier 3 one.
51
+ */
52
+ export async function runSettingsEdit(argv: readonly string[], ports: SettingsEditPorts): Promise<string | undefined> {
53
+ const { request, ask, print } = ports;
54
+ if (argv[0] === "backups") {
55
+ if (argv.length > 2 || (argv[1] !== undefined && argv[1] !== "--json")) return SETTINGS_EDIT_USAGE;
56
+ const view = await request({ t: "config_edit_get" });
57
+ const backups = Array.isArray(view.backups) ? view.backups as { name: string; file: string; at: number; bytes: number }[] : [];
58
+ if (argv[1] === "--json") print(JSON.stringify(backups, null, 2));
59
+ else if (!backups.length) print("no config backups yet");
60
+ else for (const item of backups) print(`${new Date(item.at).toISOString()} ${item.file} ${item.bytes} bytes ${item.name}`);
61
+ return undefined;
62
+ }
63
+ const parsed = parseSettingsEditArgs(argv[0] === "edit" ? argv.slice(1) : argv);
64
+ if (typeof parsed === "string") return parsed;
65
+ const view = await request({ t: "config_edit_get" });
66
+ const revisions = view.revisions as Record<string, string> | undefined;
67
+ const revision = revisions?.[parsed.file];
68
+ if (typeof revision !== "string") return "the running hub does not report config revisions: upgrade and restart it to use ahub settings edit";
69
+ const body = { file: parsed.file, revision, set: parsed.set };
70
+ const preview = await request({ t: "config_edit_preview", edit: body });
71
+ for (const line of renderEditPreview(preview)) print(line);
72
+ if (parsed.preview) { print("preview only; nothing was changed"); return undefined; }
73
+ const typed = typeof preview.confirm === "string" ? preview.confirm : undefined;
74
+ let confirm: string | undefined;
75
+ if (typed) {
76
+ const answer = await ask(`Type ${typed} to confirm: `);
77
+ if (answer === null) return "not a terminal: a key that runs code or locates secrets is confirmed by typing its name; nothing was changed";
78
+ if (answer.trim() !== typed) { print("nothing was changed"); return undefined; }
79
+ confirm = typed;
80
+ } else if (!parsed.yes) {
81
+ const answer = await ask("Write this change? [y/N] ");
82
+ if (answer === null) return "not a terminal: nothing was changed; rerun with --yes after reviewing the preview";
83
+ if (!/^(y|yes)$/i.test(answer.trim())) { print("nothing was changed"); return undefined; }
84
+ }
85
+ const result = await request({ t: "config_edit_apply", edit: body, ...(confirm ? { confirm } : {}), ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) });
86
+ print(String(result.text ?? "config written"));
87
+ if (Array.isArray(result.restartPending) && result.restartPending.length) print(`restart pending: ${result.restartPending.join(", ")}; restart the hub (ahub restart) to apply`);
88
+ return undefined;
89
+ }
@@ -328,7 +328,7 @@ export function recordPath(stateDir: string): string {
328
328
  return join(stateDir, RECORD_FILE);
329
329
  }
330
330
 
331
- /** Extra terminal ownership is supported only for the native proxy transport; other I2 extras stay headless. */
331
+ /** Resolve declared terminal roots without treating a launch candidate as daemon ownership proof. */
332
332
  function executionRoot(peer: string, projectRoot: string, captured?: string): string {
333
333
  const builtin = definitionOf(peer);
334
334
  if (builtin?.session.launchRecord) return projectRoot;
@@ -336,11 +336,6 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
336
336
  const definition = catalog.definitionOf(peer, declarations);
337
337
  if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
338
338
  if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
339
- // #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
340
- if (definition.transport === "claude-channel") {
341
- if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
342
- return projectRoot;
343
- }
344
339
  const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
345
340
  if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
346
341
  return expected;
@@ -0,0 +1,82 @@
1
+ import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
2
+ import { actionApprovalText, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
3
+ import type { ActionGateVerdict } from "../adapters/acp.ts";
4
+
5
+ /**
6
+ * Where the always-ask policy (#636) meets the two surfaces that see a tool call before it runs: the Claude PreToolUse hook and ACP
7
+ * `session/request_permission`. The classifier (`action-class.ts`) and the budget ledger (`action-policy.ts`) decide; this file only
8
+ * connects them, and only ever adds an approval: an empty result keeps the native permission policy.
9
+ */
10
+
11
+ /** One native tool call as an adapter saw it: every name it is known by, the ACP tool kind when reported, and its arguments. */
12
+ export interface ActionCall { names: readonly string[]; kind?: string; input: unknown }
13
+
14
+ /** The classes of one call. ACP names a shell call `execute` by kind whatever its tool is called, so that kind is also read as a shell. */
15
+ export function classifyCall(call: ActionCall, root: string): ActionClass[] {
16
+ const names = new Set(call.names.filter(name => typeof name === "string" && name.length > 0));
17
+ if (call.kind === "execute") names.add("bash");
18
+ const found = new Set<ActionClass>();
19
+ for (const name of names) for (const key of classifyAction(name, call.input, root)) found.add(key);
20
+ return ACTION_CLASSES.filter(key => found.has(key));
21
+ }
22
+
23
+ /**
24
+ * The classes the Claude PreToolUse hook must `ask` about, in every permission mode. The budget never relaxes it: Claude's own dialog
25
+ * cannot carry a class-budget choice, so every classed Claude call asks (a count of zero approved calls, always). A handle-less request
26
+ * selects the classes without touching any session ledger.
27
+ */
28
+ export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string): ActionClass[] {
29
+ const classes = classifyAction(tool, input, root);
30
+ if (!classes.length) return [];
31
+ const decision = budgets.request(undefined, classes);
32
+ return decision.kind === "ask" ? decision.classes : [];
33
+ }
34
+
35
+ /** The command line an approval shows: the shell command when there is one, else the arguments. Bounded by the caller's screen. */
36
+ export function commandText(input: unknown): string {
37
+ let value: unknown = input;
38
+ if (typeof input === "string") { try { value = JSON.parse(input) as unknown; } catch { return input; } }
39
+ if (typeof value === "string") return value;
40
+ if (value && typeof value === "object" && !Array.isArray(value)) {
41
+ const record = value as Record<string, unknown>, command = record.command ?? record.cmd;
42
+ if (typeof command === "string") return command;
43
+ if (Array.isArray(command) && command.every(word => typeof word === "string")) return (command as string[]).join(" ");
44
+ if (Array.isArray(record.args) && record.args.every(word => typeof word === "string")) return ["git", ...(record.args as string[])].join(" ");
45
+ }
46
+ try { return JSON.stringify(value) ?? ""; } catch { return ""; }
47
+ }
48
+
49
+ export interface ActionGatePorts<T> {
50
+ budgets: ActionBudgets;
51
+ policy(): ActionPolicyConfig | undefined;
52
+ /** The peer's execution root: a delete outside it is classed. */
53
+ root(peer: string): string;
54
+ /** The task the peer is working on, for the approval line. */
55
+ task(peer: string): T | undefined;
56
+ /** The task's public view (`Tasks.publicView`): the only way a task title reaches an approval. */
57
+ publicView(task: T): Record<string, unknown>;
58
+ /** A private turn or an open PII task: the command is withheld. */
59
+ isPrivate(peer: string): boolean;
60
+ /** The PII screen for text (`Tasks.nameable`). */
61
+ nameable(text: string): boolean;
62
+ }
63
+
64
+ /**
65
+ * The ACP gate of one peer. It returns a verdict only when a person must be asked: a classed call with no session budget left. A call
66
+ * within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
67
+ * spends here, once per request).
68
+ */
69
+ export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string) => ActionGateVerdict | undefined {
70
+ return (call, session) => {
71
+ const classes = classifyCall(call, ports.root(peer));
72
+ if (!classes.length) return undefined;
73
+ const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
74
+ if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
75
+ const title = actionApprovalText(decision.classes, commandText(call.input), ports.task(peer), {
76
+ publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined });
77
+ const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
78
+ // A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
79
+ if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };
80
+ return { title, budget: { label: decision.classes.map(key => `${key} x${limits[key]}`).join(", "), approve: () => ports.budgets.approve(challenge) } };
81
+ };
82
+ }
@@ -31,24 +31,25 @@ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
31
31
  export interface ActionSessionBudget { readonly peer: string; readonly session: string }
32
32
  export interface ActionChallenge { readonly classes: readonly ActionClass[] }
33
33
  export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
34
- interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number> }
34
+ /** `grants` counts the class-budget approvals of this session and policy: a challenge remembers it, so a later approval can tell that another one landed first. */
35
+ interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number>; grants: Record<ActionClass, number> }
35
36
  const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
36
37
  /** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
37
38
  export class ActionBudgets {
38
39
  private sessions = new Map<string, Session>();
39
- private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
40
+ private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[]; grants: Record<ActionClass, number> }>();
40
41
  constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
41
42
  bindSession(peer: string, session: string): ActionSessionBudget | undefined {
42
43
  if (!session || this.currentSession(peer) !== session) return undefined;
43
44
  const previous = this.sessions.get(peer);
44
45
  if (previous?.handle.session === session) return previous.handle;
45
46
  const handle = Object.freeze({ peer, session });
46
- this.sessions.set(peer, { handle, policy: "", remaining: empty() });
47
+ this.sessions.set(peer, { handle, policy: "", remaining: empty(), grants: empty() });
47
48
  return handle;
48
49
  }
49
50
  revoke(peer: string): void { this.sessions.delete(peer); }
50
51
  policyChanged(): void {
51
- for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
52
+ for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); state.grants = empty(); }
52
53
  this.challenges = new WeakMap();
53
54
  }
54
55
  private current(handle: ActionSessionBudget | undefined): Session | undefined {
@@ -56,7 +57,7 @@ export class ActionBudgets {
56
57
  const state = this.sessions.get(handle.peer);
57
58
  if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
58
59
  const policy = JSON.stringify(actionPolicyConfig(this.config()));
59
- if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
60
+ if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); state.grants = empty(); }
60
61
  return state;
61
62
  }
62
63
  private selected(actions: readonly ActionClass[]): ActionClass[] {
@@ -70,28 +71,39 @@ export class ActionBudgets {
70
71
  if (!state) return { kind: "ask", classes };
71
72
  if (classes.some(key => state.remaining[key] <= 0)) {
72
73
  const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
73
- this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
74
+ this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes], grants: { ...state.grants } });
74
75
  return { kind: "ask", classes, challenge };
75
76
  }
76
77
  for (const key of classes) state.remaining[key]--;
77
78
  return { kind: "budget", classes };
78
79
  }
79
- /** Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. */
80
+ /**
81
+ * Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. Requests run concurrently, so
82
+ * two challenges can be issued while a class is spent: the first approval opens the allowance (budget minus this call), and an approval of
83
+ * a challenge issued before it is a later call inside that allowance, which consumes one slot instead of opening another. With no slot
84
+ * left it is refused (the person asks again and sees a fresh challenge), so N approved calls is the most any budget lets through.
85
+ * Every class is checked before any is spent: a refusal changes nothing.
86
+ */
80
87
  approve(challenge: ActionChallenge | undefined): boolean {
81
88
  if (!challenge) return false;
82
89
  const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
83
90
  if (!grant) return false;
84
91
  const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
85
92
  if (!state || state.policy !== grant.policy) return false;
86
- for (const key of grant.classes) state.remaining[key] = Math.max(0, config.budgets[key] - 1);
93
+ const late = (key: ActionClass) => state.grants[key] !== grant.grants[key];
94
+ if (grant.classes.some(key => late(key) && state.remaining[key] <= 0)) return false;
95
+ for (const key of grant.classes) {
96
+ state.remaining[key] = late(key) ? state.remaining[key] - 1 : Math.max(0, config.budgets[key] - 1);
97
+ state.grants[key]++;
98
+ }
87
99
  return true;
88
100
  }
89
101
  /** Read-only projection: no session is created, rebound or reset by a status poll. */
90
- status(peer: string, enforced: boolean) {
102
+ status(peer: string, enforced: boolean, reason = "adapter integration pending") {
91
103
  const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
92
104
  const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
93
105
  return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
94
- enforceable: enforced, note: !enforced ? `always-ask not enforceable: adapter integration pending${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
106
+ enforceable: enforced, note: !enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
95
107
  }
96
108
  }
97
109
 
package/src/hub/board.ts CHANGED
@@ -9,6 +9,7 @@ import type { SweepRecord } from "./task-sweep.ts";
9
9
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
10
10
  import type { SdlcReceipt } from "./sdlc.ts";
11
11
  import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
12
+ import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
12
13
  import type { CostPlan, ReviewCost } from "./cost-governance.ts";
13
14
 
14
15
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
@@ -66,6 +67,14 @@ export interface HistoryEntry {
66
67
  reviewContext?: "fresh" | "continuing";
67
68
  /** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
68
69
  reviewFindings?: ReviewFindingSet;
70
+ /**
71
+ * #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
72
+ * it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
73
+ * `publicDeliverables`.
74
+ */
75
+ deliverables?: DeliverableRecord[];
76
+ /** #507b: the recorded acceptance criteria (S8 checklist ids) a verdict's unmet items named; derived by the hub from the checklist, never free text. */
77
+ unmetCriteria?: string[];
69
78
  /** Host-authored plan content revision, separate from backlog planningRevision. */
70
79
  planRevision?: { revision: number; fingerprint: string };
71
80
  planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
@@ -77,6 +86,11 @@ export interface HistoryEntry {
77
86
  reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
78
87
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
79
88
  }
89
+ /** The ids a stored verdict may carry (#507b): bounded criterion identifiers in the S8 checklist's own format, at most its 32, each once. */
90
+ function criterionIdList(value: unknown): string[] {
91
+ if (!Array.isArray(value) || value.length > 32 || value.some(id => typeof id !== "string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(id)) || new Set(value).size !== value.length) throw new Error("unmet criteria: expected up to 32 distinct criterion ids");
92
+ return [...value] as string[];
93
+ }
80
94
  export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
81
95
  function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
82
96
  const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
@@ -332,7 +346,7 @@ export class Board {
332
346
 
333
347
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
334
348
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
335
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
349
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
336
350
  const task = this.get(id);
337
351
  if (!task) throw new Error(`no task #${id}`);
338
352
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -366,6 +380,16 @@ export class Board {
366
380
  if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
367
381
  reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
368
382
  }
383
+ let deliverables: DeliverableRecord[] | undefined;
384
+ if (extra.deliverables !== undefined) {
385
+ if (!["done", "done (checking)", "lifecycle", "approved"].includes(event)) throw new Error("deliverable records require a done, a lifecycle record or an approval");
386
+ deliverables = parseDeliverableRecords(extra.deliverables);
387
+ }
388
+ let unmetCriteria: string[] | undefined;
389
+ if (extra.unmetCriteria !== undefined) {
390
+ if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("unmet criteria require a saved review verdict");
391
+ unmetCriteria = criterionIdList(extra.unmetCriteria);
392
+ }
369
393
  if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
370
394
  const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
371
395
  const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
@@ -374,7 +398,7 @@ export class Board {
374
398
  // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
375
399
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
376
400
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
377
- const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
401
+ const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
378
402
  this.taskWrite(() => {
379
403
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
380
404
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id);
package/src/hub/budget.ts CHANGED
@@ -248,6 +248,11 @@ export class Budget {
248
248
  this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
249
249
  }
250
250
 
251
+ /** A source that can no longer be trusted drops its reading of one window: the window shows unknown, never the last number. */
252
+ forget(peer: PeerId, id: UsageWindow["id"]): void {
253
+ this.readings.get(this.account(peer))?.delete(id);
254
+ }
255
+
251
256
  /** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
252
257
  private fresh(peer: PeerId): Reading[] {
253
258
  peer = this.account(peer);
package/src/hub/bus.ts CHANGED
@@ -429,14 +429,14 @@ export class Bus {
429
429
  }
430
430
 
431
431
  knownPeers(): string[] {
432
- const pending = this.storageError ? [] : this.journal?.list().filter((row) => ["queued", "dispatching", "accepted", "needs_review"].includes(row.state)).map((row) => row.peer) ?? [];
432
+ const pending = this.storageError ? [] : this.journal?.pendingPeers() ?? [];
433
433
  return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
434
434
  }
435
435
 
436
- queueList(peer?: string): JournalDelivery[] {
436
+ queueList(peer?: string, unresolvedOnly = false): JournalDelivery[] {
437
437
  if (this.journal) {
438
- const rows = this.journal.list(peer).filter((row) => !row.reason?.startsWith("grouped into delivery "));
439
- const revision = this.journal.snapshot().revision;
438
+ const rows = (unresolvedOnly ? this.journal.unresolved(peer) : this.journal.list(peer)).filter((row) => !row.reason?.startsWith("grouped into delivery "));
439
+ const revision = this.journal.revision();
440
440
  const recorded = new Set(rows.filter((r) => ["queued", "dispatching", "accepted", "needs_review"].includes(r.state)).flatMap((r) => (r.originals as Envelope[]).map((e) => `${r.peer}:${e.id}`)));
441
441
  const ids = peer ? [peer] : [...this.queues.keys()];
442
442
  for (const p of ids) for (const e of this.queues.get(p) ?? []) if (!recorded.has(`${p}:${e.id}`)) rows.push({ id: `q:${p}:${e.id}`, peer: p, state: "queued", revision, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] });
@@ -525,7 +525,7 @@ export class Bus {
525
525
  }
526
526
 
527
527
  queueSummary(peer: string): { needsReview: number; heldBy?: string; liveAccepted?: string[]; oldestQueuedAt?: number } {
528
- const rows = this.storageError ? [] : this.queueList(peer);
528
+ const rows = this.storageError ? [] : this.queueList(peer, true);
529
529
  const queued = this.queues.get(peer) ?? [];
530
530
  const review = rows.filter((r) => r.state === "needs_review");
531
531
  const needsReview = review.length;
@@ -0,0 +1,33 @@
1
+ import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { claudeStateFile } from "./claude-state.ts";
4
+
5
+ export interface ClaudeLaunchProof {
6
+ launchId: string;
7
+ executionCwd?: string;
8
+ privateRecord: boolean;
9
+ permissionHook: boolean;
10
+ unattended: boolean;
11
+ hookPurpose?: string;
12
+ }
13
+
14
+ /** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
15
+ export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchProof | undefined {
16
+ let fd: number | undefined;
17
+ try {
18
+ fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
19
+ const stat = fstatSync(fd);
20
+ // ponytail: launch metadata is capped at 16 KiB; add a versioned projection if it ever grows.
21
+ if (!stat.isFile() || stat.size > 16 * 1024) return;
22
+ const bytes = Buffer.alloc(16 * 1024 + 1);
23
+ const count = readSync(fd, bytes, 0, bytes.length, 0);
24
+ if (count > 16 * 1024) return;
25
+ const row = JSON.parse(bytes.subarray(0, count).toString("utf8"));
26
+ if (row?.instanceId !== instanceId || typeof row.launchId !== "string" || !row.launchId || row.launchId.length > 128) return;
27
+ return { launchId: row.launchId, privateRecord: stat.nlink === 1 && (stat.mode & 0o777) === 0o600 && !!process.getuid && stat.uid === process.getuid(),
28
+ ...(typeof row.executionCwd === "string" && row.executionCwd.length <= 4096 && isAbsolute(row.executionCwd) ? { executionCwd: row.executionCwd } : {}),
29
+ permissionHook: row.permissionHook === true, unattended: row.unattended === true,
30
+ ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
31
+ } catch { return; }
32
+ finally { if (fd !== undefined) closeSync(fd); }
33
+ }