@staix/agent-hub 0.12.31 → 0.12.32

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 (73) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +10 -7
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +114 -22
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +12 -8
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +191 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verified.json +20 -15
  20. package/package.json +2 -2
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +1296 -26
  23. package/src/adapters/acp.ts +5 -6
  24. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  25. package/src/adapters/definitions/kimi-native.ts +4 -0
  26. package/src/adapters/definitions/qwen-native.ts +24 -0
  27. package/src/adapters/definitions/qwen.ts +71 -0
  28. package/src/adapters/pi.ts +6 -2
  29. package/src/adapters/registry-types.ts +174 -0
  30. package/src/adapters/registry.ts +36 -0
  31. package/src/cli/console-state.ts +74 -5
  32. package/src/cli/console.ts +35 -2
  33. package/src/cli/help.ts +3 -1
  34. package/src/cli/identity.ts +13 -3
  35. package/src/cli/launch.ts +1 -6
  36. package/src/cli/main.ts +200 -39
  37. package/src/cli/output.ts +2 -0
  38. package/src/cli/preview.ts +8 -5
  39. package/src/cli/recovery-guidance.ts +137 -0
  40. package/src/cli/recovery-presentation-options.ts +8 -0
  41. package/src/cli/recovery-session-file.ts +18 -0
  42. package/src/cli/terminal-recovery.ts +48 -0
  43. package/src/cli/upgrade-interactive.ts +129 -75
  44. package/src/cli/upgrade-labels.ts +30 -0
  45. package/src/cli/upgrade-presentation-data.ts +41 -0
  46. package/src/cli/upgrade-presentation-flags.ts +31 -0
  47. package/src/cli/upgrade-presentation.ts +217 -0
  48. package/src/cli/upgrade-restore-state.ts +6 -2
  49. package/src/cli/upgrade-runtime.ts +345 -194
  50. package/src/cli/upgrade.ts +276 -25
  51. package/src/hub/bus.ts +11 -0
  52. package/src/hub/cohorts.ts +2 -2
  53. package/src/hub/config-apply.ts +242 -0
  54. package/src/hub/config-reload.ts +120 -0
  55. package/src/hub/config-trust.ts +11 -2
  56. package/src/hub/crash.ts +8 -6
  57. package/src/hub/daemon.ts +559 -170
  58. package/src/hub/events.ts +2 -2
  59. package/src/hub/manager.ts +1 -1
  60. package/src/hub/peer-catalog.ts +10 -13
  61. package/src/hub/peers.ts +12 -2
  62. package/src/hub/permission-mode.ts +5 -3
  63. package/src/hub/recovery-continuity.ts +152 -0
  64. package/src/hub/recovery-store.ts +10 -4
  65. package/src/hub/sdlc.ts +5 -1
  66. package/src/hub/settings.ts +50 -36
  67. package/src/hub/settled-roles.ts +35 -0
  68. package/src/hub/supervision.ts +3 -2
  69. package/src/hub/tasks.ts +28 -8
  70. package/src/hub/ui.ts +1 -1
  71. package/src/memory/brief.ts +6 -5
  72. package/src/pi/session.ts +23 -4
  73. package/src/ui/index.html +23 -7
package/src/hub/events.ts CHANGED
@@ -14,9 +14,9 @@ export const EVENTS_SCHEMA = 1;
14
14
  */
15
15
  export type HubEvent =
16
16
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
17
- /** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
18
17
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
19
- | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
18
+ /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
19
+ | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean; reason?: string }
20
20
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
21
21
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
22
22
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -155,7 +155,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
155
155
  return { ok: true };
156
156
  }
157
157
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
158
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
158
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
159
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
160
160
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
161
  let nominalMs = 10_000;
@@ -1,4 +1,8 @@
1
- /** Native peers the hub knows; membership checks share this list. */
1
+ import { definitionOf } from "../adapters/registry.ts";
2
+
3
+ // ponytail: older staged coordinators read these literal declarations to probe target support.
4
+ // A2 replaces that source probe with a published capability before deleting the compatibility exports.
5
+ /** Native peers the hub knows; membership checks share the registry. */
2
6
  export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
3
7
  /** Optional production peers are added explicitly; legacy defaults remain stable. */
4
8
  export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
@@ -6,25 +10,18 @@ export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
6
10
  export type KnownPeer = typeof SUPPORTED_PEERS[number];
7
11
 
8
12
  export const isKnownPeer = (value: unknown): value is KnownPeer =>
9
- typeof value === "string" && (KNOWN_PEERS as readonly string[]).includes(value);
13
+ typeof value === "string" && !!definitionOf(value) && !definitionOf(value)!.optional;
10
14
 
11
15
  export const isSupportedPeer = (value: unknown): value is KnownPeer =>
12
- typeof value === "string" && (SUPPORTED_PEERS as readonly string[]).includes(value);
16
+ typeof value === "string" && !!definitionOf(value);
13
17
 
14
18
  // Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
15
- const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
16
- claude: ["planner", "reviewer"],
17
- codex: ["implementer"],
18
- kimi: ["implementer", "verifier"],
19
- local: ["implementer", "verifier"],
20
- pi: ["implementer", "verifier"],
21
- qwen: ["implementer", "verifier"],
22
- jcode: ["implementer", "verifier"],
23
- };
19
+ const ROLE_ORDER: readonly KnownPeer[] = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode"];
24
20
 
25
21
  /** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
26
22
  export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
27
- return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
23
+ return Object.fromEntries(ROLE_ORDER.filter(peer => includeOptional || !definitionOf(peer)!.optional)
24
+ .map(peer => [peer, [...definitionOf(peer)!.roles]]));
28
25
  }
29
26
 
30
27
  /** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
package/src/hub/peers.ts CHANGED
@@ -44,18 +44,28 @@ export abstract class BasePeer implements PeerAdapter {
44
44
  onDelivery?: (receipt: DeliveryReceipt) => void;
45
45
  private _state: PeerState = "offline";
46
46
  private timer: ReturnType<typeof setTimeout> | undefined;
47
+ private nextWatchdogMs: number | undefined;
47
48
 
48
49
  constructor(
49
50
  readonly id: PeerId,
50
- protected readonly watchdogMs = DEFAULT_WATCHDOG_MS,
51
+ protected watchdogMs = DEFAULT_WATCHDOG_MS,
51
52
  ) {}
52
53
 
53
54
  get state(): PeerState {
54
55
  return this._state;
55
56
  }
56
57
 
58
+ /** Keep the active turn's inactivity bound; a changed setting belongs to the next busy turn. */
59
+ configureWatchdog(ms: number): void {
60
+ if (!Number.isFinite(ms) || ms <= 0 || ms > 2_147_483_647) throw new Error("invalid watchdog interval");
61
+ this.nextWatchdogMs = ms;
62
+ }
63
+
57
64
  protected setState(next: PeerState): void {
58
- if (next === "busy") this.touch();
65
+ if (next === "busy") {
66
+ if (this._state !== "busy" && this.nextWatchdogMs !== undefined) this.watchdogMs = this.nextWatchdogMs;
67
+ this.touch();
68
+ }
59
69
  else this.clearWatchdog();
60
70
  if (next === this._state) return;
61
71
  this._state = next;
@@ -1,12 +1,13 @@
1
1
  import { isSupportedPeer } from "./peer-catalog.ts";
2
+ import { definitionOf } from "../adapters/registry.ts";
2
3
  import { lstatSync } from "node:fs";
3
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
5
 
5
6
  /** Operator-selected approval frequency; agent sandboxes remain separate. */
6
7
  export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
7
8
  export type PermissionMode = typeof PERMISSION_MODES[number];
8
- export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
9
- export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
9
+ export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
10
+ export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
10
11
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
11
12
  export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
12
13
  if (value === undefined) return {};
@@ -29,9 +30,10 @@ export function permissionBoundary(peer: string): string {
29
30
  }
30
31
  /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
31
32
  export function permissionGrant(peer: string, mode: PermissionMode): string {
33
+ const grant = definitionOf(peer)?.permission.grants?.[mode];
34
+ if (grant !== undefined) return grant;
32
35
  if (peer === "jcode") return "no verified hub mode mapping; native policy only";
33
36
  if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
34
- if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
35
37
  if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
36
38
  if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
37
39
  if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
@@ -0,0 +1,152 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync } from "node:fs";
4
+ import { isAbsolute, join, normalize } from "node:path";
5
+ import { realPath } from "./project.ts";
6
+ import { atomicPrivateJSON, operationIdPattern } from "./recovery-store.ts";
7
+
8
+ export const RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS = 3000;
9
+ const MAX_BOOKMARK_BYTES = 16 * 1024;
10
+ const BOOKMARK = "recovery-continuity.json", LOCK = "recovery-continuity-lock.db";
11
+
12
+ export interface RecoveryContinuityBookmark {
13
+ schema: 1;
14
+ operationId: string;
15
+ projectId: string;
16
+ projectRoot: string;
17
+ at: number;
18
+ pi: { sessionId: string; sessionFile?: string; backend?: "auto" | "dgx" | "mlx"; model?: string };
19
+ }
20
+ export interface RecoveryContinuityExpected { projectId: string; projectRoot: string; operationId?: string }
21
+ export type ContinuityRetirementReason = "authenticated_session_changed" | "resume_unavailable" | "recovery_released";
22
+ export type RecoveryContinuityReading =
23
+ | { state: "absent" }
24
+ | { state: "invalid"; reason: "unavailable" | "invalid_shape" | "invalid_path" | "identity_mismatch" | "oversized" }
25
+ | { state: "ready"; bookmark: RecoveryContinuityBookmark; digest: string };
26
+
27
+ const unavailable = (): never => { throw new Error("recovery continuity metadata unavailable or invalid"); };
28
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
29
+ const keys = (value: Record<string, unknown>, allowed: readonly string[]): boolean => Object.keys(value).every(key => allowed.includes(key));
30
+ const text = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
31
+ const id = (value: unknown): value is string => text(value, 256) && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value);
32
+ const absolute = (value: unknown): value is string => text(value, 4096) && isAbsolute(value) && normalize(value) === value;
33
+ function canonicalRoot(value: unknown): string {
34
+ if (!absolute(value)) return unavailable();
35
+ const root = realPath(value);
36
+ if (!lstatSync(root).isDirectory()) return unavailable();
37
+ return root;
38
+ }
39
+ function checked(value: unknown): RecoveryContinuityBookmark {
40
+ if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", "pi"]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
41
+ const pi = value.pi;
42
+ if (!record(pi) || !keys(pi, ["sessionId", "sessionFile", "backend", "model"]) || !id(pi.sessionId) || pi.sessionFile !== undefined && !absolute(pi.sessionFile) || pi.backend !== undefined && !["auto", "dgx", "mlx"].includes(String(pi.backend)) || pi.model !== undefined && !text(pi.model, 4096)) return unavailable();
43
+ const projectRoot = canonicalRoot(value.projectRoot);
44
+ return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number,
45
+ pi: { sessionId: pi.sessionId, ...(pi.sessionFile === undefined ? {} : { sessionFile: pi.sessionFile as string }), ...(pi.backend === undefined ? {} : { backend: pi.backend as "auto" | "dgx" | "mlx" }), ...(pi.model === undefined ? {} : { model: pi.model as string }) } };
46
+ }
47
+ function stateDirectory(stateDir: string): void {
48
+ if (!absolute(stateDir)) return unavailable();
49
+ const stat = lstatSync(stateDir);
50
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable();
51
+ }
52
+ function privateFile(path: string, missing = false): void {
53
+ try {
54
+ const stat = lstatSync(path);
55
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.mode & 0o077) return unavailable();
56
+ } catch (error) { if (!missing || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
57
+ }
58
+ const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
59
+
60
+ /** Read one bounded project-local file; invalid evidence is deliberately distinct from no bookmark. */
61
+ export function readRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected): RecoveryContinuityReading {
62
+ let fd: number | undefined;
63
+ let expectedRoot: string;
64
+ try { expectedRoot = canonicalRoot(expected.projectRoot); }
65
+ catch { return { state: "invalid", reason: "identity_mismatch" }; }
66
+ try {
67
+ stateDirectory(stateDir);
68
+ const file = join(stateDir, BOOKMARK);
69
+ privateFile(file);
70
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
71
+ const stat = fstatSync(fd);
72
+ if (!stat.isFile() || stat.mode & 0o077) return { state: "invalid", reason: "invalid_path" };
73
+ if (stat.size > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
74
+ const buffer = Buffer.alloc(MAX_BOOKMARK_BYTES + 1);
75
+ let length = 0;
76
+ while (length < buffer.length) { const n = readSync(fd, buffer, length, buffer.length - length, null); if (!n) break; length += n; }
77
+ if (length > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
78
+ const bytes = buffer.subarray(0, length);
79
+ let bookmark: RecoveryContinuityBookmark;
80
+ try {
81
+ const raw: unknown = JSON.parse(bytes.toString("utf8"));
82
+ bookmark = checked(raw);
83
+ if (!record(raw) || raw.projectRoot !== bookmark.projectRoot) return { state: "invalid", reason: "invalid_path" };
84
+ } catch { return { state: "invalid", reason: "invalid_shape" }; }
85
+ if (!id(expected.projectId) || expected.operationId !== undefined && !operationIdPattern.test(expected.operationId) || bookmark.projectId !== expected.projectId || bookmark.projectRoot !== expectedRoot || expected.operationId !== undefined && bookmark.operationId !== expected.operationId) return { state: "invalid", reason: "identity_mismatch" };
86
+ return { state: "ready", bookmark, digest: digest(bytes) };
87
+ } catch (error) {
88
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "invalid", reason: "unavailable" };
89
+ } finally { if (fd !== undefined) closeSync(fd); }
90
+ }
91
+
92
+ /** A crash releases SQLite's lock; no lockfile ownership or stale-process inference is needed. */
93
+ function withWriter<T>(stateDir: string, action: () => T): T {
94
+ stateDirectory(stateDir);
95
+ const file = join(stateDir, LOCK);
96
+ privateFile(file, true);
97
+ for (const suffix of ["-journal", "-wal", "-shm"]) privateFile(file + suffix, true);
98
+ const db = new Database(file, { create: true });
99
+ db.run(`PRAGMA busy_timeout = ${RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS}`);
100
+ try {
101
+ chmodSync(file, 0o600);
102
+ db.run("BEGIN IMMEDIATE");
103
+ try { return action(); } finally { db.run("ROLLBACK"); }
104
+ } finally { db.close(); }
105
+ }
106
+
107
+ /** Called only after the coordinator confirmed its target stopped and archived that operation's snapshot. */
108
+ export function writeRecoveryContinuity(stateDir: string, value: RecoveryContinuityBookmark): { digest: string } {
109
+ const bookmark = checked(value), bytes = Buffer.from(JSON.stringify(bookmark, null, 2) + "\n");
110
+ if (bytes.length > MAX_BOOKMARK_BYTES) return unavailable();
111
+ return withWriter(stateDir, () => {
112
+ privateFile(join(stateDir, BOOKMARK), true);
113
+ atomicPrivateJSON(join(stateDir, BOOKMARK), bookmark);
114
+ return { digest: digest(bytes) };
115
+ });
116
+ }
117
+
118
+ /** Consume only the exact bookmark whose recorded session has authenticated; stale receipts retain newer data. */
119
+ export function consumeRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected & { operationId: string; digest: string }): boolean {
120
+ if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return false;
121
+ return withWriter(stateDir, () => {
122
+ const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
123
+ if (current.state === "absent") return false;
124
+ if (current.state === "invalid") return unavailable();
125
+ if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return false;
126
+ unlinkSync(join(stateDir, BOOKMARK));
127
+ return true;
128
+ });
129
+ }
130
+
131
+ /** Preserve superseded evidence privately; only a matching project/operation/digest may retire the active file. */
132
+ export function retireRecoveryContinuity(
133
+ stateDir: string,
134
+ expected: RecoveryContinuityExpected & { operationId: string; digest: string },
135
+ reason: ContinuityRetirementReason,
136
+ ): { retired: boolean; archive?: string } {
137
+ if (!["authenticated_session_changed", "resume_unavailable", "recovery_released"].includes(reason)) return unavailable();
138
+ if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return { retired: false };
139
+ return withWriter(stateDir, () => {
140
+ const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
141
+ if (current.state === "absent") return { retired: false };
142
+ if (current.state === "invalid") return unavailable();
143
+ if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return { retired: false };
144
+ // A unique destination preserves an earlier retirement even when the same bookmark was explicitly restored.
145
+ const archive = join(stateDir, `recovery-continuity.retired.${reason}.${current.digest}.${randomUUID()}.json`);
146
+ try { lstatSync(archive); return unavailable(); }
147
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
148
+ renameSync(join(stateDir, BOOKMARK), archive);
149
+ chmodSync(archive, 0o600);
150
+ return { retired: true, archive };
151
+ });
152
+ }
@@ -69,15 +69,21 @@ export function assertLifecycleAvailable(): void {
69
69
  * lacks dispose, refuses abort and resume on a lapsed hold and shows no next actions: then the running release is
70
70
  * named, which does all of that in-process (its resume still runs the operation's own runner).
71
71
  */
72
- export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags = ""): string {
73
- const entry = !op.sourceRoot ? undefined : coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
72
+ export type RecoveryAction = "status" | "resume" | "abort" | "dispose" | "reattach";
73
+ export function supportsAction(sourceRoot: string, action: RecoveryAction): boolean {
74
+ if (!coordinatorCurrent(sourceRoot)) return false;
75
+ if (action !== "reattach") return true;
76
+ try { return readFileSync(join(sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export async function reattachRecovery"); } catch { return false; }
77
+ }
78
+ export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags = ""): string {
79
+ const entry = !op.sourceRoot ? undefined : supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
74
80
  const cli = !entry ? "ahub" : /^[\w./@+-]+$/.test(entry) ? `bun ${entry}` : `bun '${entry.replace(/'/g, `'\\''`)}'`;
75
81
  return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
76
82
  }
77
83
 
78
84
  /** #272: the command above as an argv for `bun`, which a screen runs instead of printing; with no coordinator of its own, this release's. */
79
- export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags: string[] = []): string[] {
80
- const entry = op.sourceRoot && coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
85
+ export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags: string[] = []): string[] {
86
+ const entry = op.sourceRoot && supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
81
87
  return [entry, "recovery", action, op.id, ...flags];
82
88
  }
83
89
 
package/src/hub/sdlc.ts CHANGED
@@ -3,6 +3,7 @@ import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
6
+ import { hostExecutable } from "./host-executable.ts";
6
7
 
7
8
  export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
8
9
  export type SdlcBaseStage = typeof SDLC_STAGES[number];
@@ -219,7 +220,10 @@ export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutpu
219
220
  /** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
220
221
  export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
221
222
  const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
222
- const child = spawn("gh", args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
223
+ let child: ReturnType<typeof spawn>;
224
+ try {
225
+ child = spawn(hostExecutable("gh", cwd ?? process.cwd()), args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
226
+ } catch { resolve({ code: 1, stdout: "" }); return; }
223
227
  let stdout = "", failed = false;
224
228
  const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
225
229
  const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
@@ -4,7 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameS
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { CLASSES, type TaskClass } from "./board.ts";
7
- import { configTracked } from "./config-trust.ts";
7
+ import { configTracked, configRepositoryPresent } from "./config-trust.ts";
8
8
  import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
9
9
  import { START_MODE_PEERS, START_MODES } from "./start-mode.ts";
10
10
  import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
@@ -21,14 +21,15 @@ export interface SettingDef {
21
21
  key: string;
22
22
  group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
23
23
  label: string;
24
- type: "enum" | "boolean" | "peers" | "roles" | "integer";
24
+ type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number";
25
25
  min?: number; max?: number;
26
26
  /** The closed values of an enum; a route setting takes the project's own route ids instead. */
27
27
  values?: readonly string[];
28
28
  /** `runtime` lives in the running hub only; the others are the machine-local file that holds the value. */
29
29
  store: "runtime" | "config" | "routing";
30
- /** `peer start`: stored, and taken up by the running hub at that peer's next start. */
31
- applies: "live" | "peer start" | "hub start";
30
+ /** `next use` keeps an active turn/session intact; `restart` retains the running value until a new daemon. */
31
+ applies: "live" | "next use" | "restart";
32
+ restartReason?: string;
32
33
  /** `raises`: only a settings session or a person's terminal may set it, except to `floor`. */
33
34
  risk: "safe" | "raises";
34
35
  floor?: string | boolean | number;
@@ -45,21 +46,23 @@ const perClass = (name: TaskClass): SettingDef[] => [
45
46
  { key: `routing.classes.${name}.pi_backend`, group: "Routing", label: `${name}: Pi backend`, type: "enum", values: ["dgx", "mlx"], store: "routing", applies: "live", risk: "raises", path: ["classes", name, "pi_backend"] },
46
47
  ];
47
48
  export const SETTINGS: readonly SettingDef[] = [
48
- ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
49
+ ...["enabled", "auto_reassign"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["task_sweep", key] })),
50
+ ...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
51
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
49
52
  { key: "planner", group: "Roles", label: "Planner (the one peer that plans)", type: "enum", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
50
- ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
53
+ ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
51
54
  { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
52
55
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
53
56
  ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
54
- ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
55
- ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "peer start", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
56
- { key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "hub start", risk: "raises", floor: false, path: ["pi", "auto_start"] },
57
+ ...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
58
+ ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
59
+ { key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "next use", risk: "raises", floor: false, path: ["pi", "auto_start"] },
57
60
  { key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
58
61
  ...CLASSES.flatMap(perClass),
59
- { key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["research", "enabled"] },
60
- { key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["approvals", "notify"] },
61
- { key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["snapshots", "enabled"] },
62
- { key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "hub start", risk: "safe", path: ["coordination"] },
62
+ { key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["research", "enabled"] },
63
+ { key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
64
+ { key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["snapshots", "enabled"] },
65
+ { key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "next use", risk: "safe", path: ["coordination"] },
63
66
  ];
64
67
 
65
68
  /**
@@ -83,8 +86,8 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
83
86
  if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
84
87
  return null;
85
88
  }
86
- if (def.type === "integer") {
87
- if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes an integer from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
89
+ if (def.type === "integer" || def.type === "number") {
90
+ if (typeof raw !== "number" || !(def.type === "integer" ? Number.isSafeInteger(raw) : Number.isFinite(raw)) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes ${def.type === "integer" ? "an integer" : "a number"} from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
88
91
  return raw;
89
92
  }
90
93
  if (def.type === "boolean") {
@@ -108,6 +111,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
108
111
  /** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
109
112
  export function parseSettingText(def: SettingDef, text: string): unknown {
110
113
  if (text === "inherit") return null;
114
+ if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
111
115
  if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
112
116
  if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
113
117
  if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
@@ -163,14 +167,7 @@ function refuseTracked(cwd: string, file: StoredFile): void {
163
167
  const tracked = configTracked(cwd, file);
164
168
  if (tracked) throw new Error(`.agenthub/${file} is committed to git; settings write only machine-local files. Remove it from the repository (git rm --cached) and ignore it`);
165
169
  // Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
166
- if (tracked === undefined && insideRepository(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
167
- }
168
- /** Whether this directory or one above it holds a `.git`: a repository git should have been able to answer for. */
169
- function insideRepository(dir: string): boolean {
170
- for (let at = dir; ; at = dirname(at)) {
171
- if (existsSync(join(at, ".git"))) return true;
172
- if (dirname(at) === at) return false;
173
- }
170
+ if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
174
171
  }
175
172
  /** Keeps the one previous version, then replaces (or removes) the file. */
176
173
  function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
@@ -263,7 +260,7 @@ function checkConfigText(cwd: string, text: string | null, validate: (scratch: s
263
260
  * Sets (or with null removes) one key of `.agenthub/routing.local.toml`; the real parser reads the result first, and
264
261
  * `check` may refuse the policy it yields. Undefined when the overlay already holds that value: nothing is written.
265
262
  */
266
- export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, check?: (routing: Routing) => void): Routing | undefined {
263
+ export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, check?: (routing: Routing, overlay?: RoutingOverlay) => void): Routing | undefined {
267
264
  const file = join(cwd, ".agenthub", OVERLAY_FILE);
268
265
  const before = readText(file);
269
266
  const current = before === null ? {} : parseOverlay(before);
@@ -271,7 +268,7 @@ export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingD
271
268
  if (value === null ? held === undefined : same(held, value)) return undefined;
272
269
  const overlay = routingCandidate(current, def, value);
273
270
  const routing = parseRouting(routingText(cwd), overlay);
274
- check?.(routing);
271
+ check?.(routing, overlay);
275
272
  const empty = !overlay.stay_switch && !Object.keys(overlay.classes ?? {}).length;
276
273
  store(cwd, stateDir, OVERLAY_FILE, def.key, before, empty ? null : overlayToml(overlay));
277
274
  return routing;
@@ -302,11 +299,13 @@ export function readOverlay(cwd: string): RoutingOverlay {
302
299
  * Puts back the one previous version of the file the last settings write changed. Refused when the file changed since
303
300
  * that write (a hand edit would be lost), when the loaders no longer accept that version, or when there is nothing to undo.
304
301
  */
305
- export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing) => void): { key: string; file: StoredFile } {
302
+ export interface UndoIdentity { key: string; undoDigest: string }
303
+ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing, overlay?: RoutingOverlay) => void, expected?: UndoIdentity): { key: string; file: StoredFile } {
306
304
  const raw = readText(undoFile(stateDir));
307
305
  if (raw === null) throw new Error("nothing to undo");
308
306
  let record: UndoRecord;
309
307
  try { record = JSON.parse(raw) as UndoRecord; } catch { throw new Error("nothing to undo"); }
308
+ if (expected && (record.key !== expected.key || digest(raw) !== expected.undoDigest)) throw new Error("undo record changed since preview; preview again; nothing was undone");
310
309
  if (record.file !== CONFIG_LOCAL && record.file !== OVERLAY_FILE) throw new Error("nothing to undo");
311
310
  const path = join(cwd, ".agenthub", record.file);
312
311
  if (digest(readText(path)) !== record.written) throw new Error(`.agenthub/${record.file} changed since the last settings write; nothing was undone`);
@@ -314,10 +313,12 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
314
313
  refuseTracked(cwd, record.file);
315
314
  if (record.file === OVERLAY_FILE) {
316
315
  // The parser always reads what is put back; the caller's check is on top of it.
317
- const restored = parseRouting(routingText(cwd), record.previous === null ? undefined : parseOverlay(record.previous));
318
- checkRouting?.(restored);
316
+ const overlay = record.previous === null ? undefined : parseOverlay(record.previous);
317
+ const restored = parseRouting(routingText(cwd), overlay);
318
+ checkRouting?.(restored, overlay);
319
319
  }
320
320
  else checkConfigText(cwd, record.previous, validate);
321
+ if (expected && readText(undoFile(stateDir)) !== raw) throw new Error("undo record changed since preview; preview again; nothing was undone");
321
322
  replaceFile(path, record.file, record.previous);
322
323
  rmSync(undoFile(stateDir), { force: true });
323
324
  return { key: String(record.key), file: record.file };
@@ -335,13 +336,23 @@ export function restoredValue(cwd: string, stateDir: string, def: SettingDef, in
335
336
  const parse = (text: string | null): unknown => { try { return text === null ? {} : JSON.parse(text); } catch { return {}; } };
336
337
  let local: unknown = {};
337
338
  if (!inherit) {
338
- try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; if (record.file !== CONFIG_LOCAL) return undefined; local = parse(record.previous); } catch { return undefined; }
339
+ try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord;
340
+ if (record.file === OVERLAY_FILE && def.store === "routing") return valueAt(parseRouting(routingText(cwd), record.previous === null ? undefined : parseOverlay(record.previous)), def.path);
341
+ if (record.file !== CONFIG_LOCAL) return undefined; local = parse(record.previous);
342
+ } catch { return undefined; }
339
343
  }
340
344
  return valueAt(local, def.path) ?? valueAt(parse(readText(join(cwd, ".agenthub", "config.json"))), def.path);
341
345
  }
342
346
  /** The key the next undo would put back, if any. */
343
347
  export function pendingUndo(stateDir: string): string | undefined {
344
- try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; return typeof record.key === "string" ? record.key : undefined; } catch { return undefined; }
348
+ return pendingUndoIdentity(stateDir)?.key;
349
+ }
350
+ /** Snapshot identity of the exact record, including its previous bytes, file and write time. */
351
+ export function pendingUndoIdentity(stateDir: string): UndoIdentity | undefined {
352
+ try {
353
+ const raw = readFileSync(undoFile(stateDir), "utf8"), record = JSON.parse(raw) as UndoRecord;
354
+ return typeof record.key === "string" && record.key.length > 0 ? { key: record.key, undoDigest: digest(raw)! } : undefined;
355
+ } catch { return undefined; }
345
356
  }
346
357
 
347
358
  /** The loader's line saying it ignored this setting's field in the machine-local file, if it did (config trust, #17). */
@@ -355,9 +366,12 @@ export interface SettingRow {
355
366
  key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
356
367
  /** The file a write goes to, or "this hub run". */
357
368
  file: string;
358
- applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean | number;
369
+ applies: SettingDef["applies"] | "peer start" | "hub start"; risk: SettingDef["risk"]; floor?: string | boolean | number;
359
370
  /** What is in force now; null when nothing sets it. */
360
371
  value: SettingValue;
372
+ /** Optional for replies from pre-#439 daemons; current projections always supply it. */
373
+ fileValue?: SettingValue;
374
+ restartReason?: string;
361
375
  /** Which file the value in force comes from. */
362
376
  source: string;
363
377
  /** The stored value the next hub start reads, when it differs from the one in force. */
@@ -390,23 +404,23 @@ export function settingRows(input: {
390
404
  const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
391
405
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
392
406
  return SETTINGS.map((def): SettingRow => {
393
- const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(def.type === "integer" ? { min: def.min, max: def.max } : {}), applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
407
+ const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
394
408
  ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
395
409
  if (def.store === "runtime") {
396
410
  const mode = input.mode(def.path[0]!);
397
- return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
411
+ return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, fileValue: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
398
412
  }
399
413
  if (def.store === "routing") {
400
414
  const value = scalar(def.path[0] === "stay_switch" ? input.routing.stay_switch : (input.routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
401
- return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
415
+ return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, fileValue: value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
402
416
  ...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
403
417
  }
404
- if (def.type === "roles" && input.roles) return { ...base, ...input.roles(def.path[1]!), file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." };
418
+ if (def.type === "roles" && input.roles) { const roles = input.roles(def.path[1]!); return { ...base, ...roles, fileValue: roles.pending !== undefined ? roles.pending : roles.value, file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." }; }
405
419
  const value = scalar(input.running(def.path));
406
420
  const stored = input.next ? scalar(input.next(def.path)) : value;
407
421
  const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
408
422
  const dropped = ignoredLine(input.ignored, def);
409
- return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, source, ...(same(value, stored) ? {} : { pending: stored }),
423
+ return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, fileValue: stored, source, ...(same(value, stored) ? {} : { pending: stored }),
410
424
  ...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };
411
425
  });
412
426
  }
@@ -0,0 +1,35 @@
1
+ import { conductorPeer } from "./conductor.ts";
2
+ import { PLANNER_PEERS } from "./peer-catalog.ts";
3
+
4
+ export interface SettledRoles {
5
+ roles: Record<string, string[]>;
6
+ source: "setting" | "roles" | "automatic" | "none";
7
+ planners: string[];
8
+ conductor?: string;
9
+ }
10
+
11
+ /** Resolve the runtime planner fallback without changing the configured policy role map. */
12
+ export function resolveSettledRoles(
13
+ policyRoles: Readonly<Record<string, readonly string[]>>,
14
+ planner: string | undefined,
15
+ rostered: (peer: string) => boolean,
16
+ ): SettledRoles {
17
+ const roles = Object.fromEntries(Object.entries(policyRoles).map(([peer, list]) => [peer, [...list]]));
18
+ const pinned = (PLANNER_PEERS as readonly string[]).includes(planner ?? "") ? planner! : undefined;
19
+ let source: SettledRoles["source"];
20
+ if (pinned) {
21
+ for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
22
+ roles[pinned] = ["planner", ...(roles[pinned] ?? [])];
23
+ source = "setting";
24
+ } else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && rostered(peer))) source = "roles";
25
+ else {
26
+ const auto = PLANNER_PEERS.find(rostered);
27
+ if (auto) {
28
+ for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
29
+ roles[auto] = [...(roles[auto] ?? []), "planner"];
30
+ }
31
+ source = auto ? "automatic" : "none";
32
+ }
33
+ const conductor = conductorPeer(roles);
34
+ return { roles, source, planners: Object.keys(roles).filter(peer => roles[peer]!.includes("planner")).sort(), ...(conductor === null ? {} : { conductor }) };
35
+ }
@@ -31,7 +31,7 @@ export interface SupervisionDeps {
31
31
  isPrivate: (task: Task) => boolean;
32
32
  /** false means not admitted, so latches must remain retryable. No separate batching timer. */
33
33
  emit: (notice: SupervisionNotice) => boolean | void;
34
- approvalAgeMs?: number;
34
+ approvalAgeMs?: number | (() => number);
35
35
  now?: () => number;
36
36
  /** Durable round state belongs to the daemon's store, not the controller. */
37
37
  readRound?: (peer: string) => string | undefined;
@@ -127,10 +127,11 @@ export class SupervisionFeed {
127
127
  }
128
128
 
129
129
  approvalWaiting(request: { id: string; peer: string; tool: string; createdAt: number }): boolean {
130
+ const approvalAgeMs = typeof this.d.approvalAgeMs === "function" ? this.d.approvalAgeMs() : this.d.approvalAgeMs ?? 60_000;
130
131
  const peer = this.recipient();
131
132
  const age = (this.d.now?.() ?? Date.now()) - request.createdAt;
132
133
  const key = `${peer}:${request.id}`;
133
- if (!peer || !Number.isFinite(age) || age < (this.d.approvalAgeMs ?? 60_000) || this.approvals.has(key)) return false;
134
+ if (!peer || !Number.isFinite(age) || age < approvalAgeMs || this.approvals.has(key)) return false;
134
135
  const sent = this.send(`approval:${request.id}`, `${request.peer}: approval waiting; tool ${request.tool}; age ${Math.floor(age / 1000)}s; ask the person to answer it in ahub console`, "important");
135
136
  if (sent) this.approvals.add(key);
136
137
  return sent;