@staix/agent-hub 0.12.31 → 0.12.33

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 (79) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +14 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +3 -0
  5. package/docs/agent-notes/daemon.md +13 -8
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +4 -3
  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 +141 -25
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +18 -9
  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 +211 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  20. package/docs/verified.json +24 -16
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1296 -26
  24. package/src/adapters/acp.ts +5 -6
  25. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  26. package/src/adapters/definitions/kimi-native.ts +4 -0
  27. package/src/adapters/definitions/qwen-native.ts +24 -0
  28. package/src/adapters/definitions/qwen.ts +71 -0
  29. package/src/adapters/pi.ts +6 -2
  30. package/src/adapters/registry-types.ts +174 -0
  31. package/src/adapters/registry.ts +36 -0
  32. package/src/cli/console-state.ts +74 -5
  33. package/src/cli/console.ts +35 -2
  34. package/src/cli/help.ts +3 -1
  35. package/src/cli/identity.ts +13 -3
  36. package/src/cli/launch.ts +1 -6
  37. package/src/cli/main.ts +200 -39
  38. package/src/cli/output.ts +2 -0
  39. package/src/cli/preview.ts +8 -5
  40. package/src/cli/recovery-guidance.ts +137 -0
  41. package/src/cli/recovery-presentation-options.ts +8 -0
  42. package/src/cli/recovery-session-file.ts +18 -0
  43. package/src/cli/reset.ts +13 -1
  44. package/src/cli/terminal-recovery.ts +48 -0
  45. package/src/cli/upgrade-interactive.ts +129 -75
  46. package/src/cli/upgrade-labels.ts +30 -0
  47. package/src/cli/upgrade-presentation-data.ts +41 -0
  48. package/src/cli/upgrade-presentation-flags.ts +31 -0
  49. package/src/cli/upgrade-presentation.ts +227 -0
  50. package/src/cli/upgrade-restore-state.ts +6 -2
  51. package/src/cli/upgrade-runtime.ts +360 -199
  52. package/src/cli/upgrade.ts +278 -25
  53. package/src/hub/bus.ts +144 -14
  54. package/src/hub/cohorts.ts +2 -2
  55. package/src/hub/config-apply.ts +242 -0
  56. package/src/hub/config-reload.ts +120 -0
  57. package/src/hub/config-trust.ts +11 -2
  58. package/src/hub/control-client.ts +1 -1
  59. package/src/hub/crash.ts +8 -6
  60. package/src/hub/daemon.ts +577 -179
  61. package/src/hub/deferred-hub.ts +60 -0
  62. package/src/hub/delivery-journal.ts +6 -0
  63. package/src/hub/events.ts +2 -2
  64. package/src/hub/manager.ts +1 -1
  65. package/src/hub/peer-catalog.ts +10 -13
  66. package/src/hub/peers.ts +12 -2
  67. package/src/hub/permission-mode.ts +5 -3
  68. package/src/hub/recovery-continuity.ts +152 -0
  69. package/src/hub/recovery-store.ts +10 -4
  70. package/src/hub/restart.ts +12 -1
  71. package/src/hub/sdlc.ts +5 -1
  72. package/src/hub/settings.ts +50 -36
  73. package/src/hub/settled-roles.ts +35 -0
  74. package/src/hub/supervision.ts +3 -2
  75. package/src/hub/tasks.ts +29 -9
  76. package/src/hub/ui.ts +1 -1
  77. package/src/memory/brief.ts +6 -5
  78. package/src/pi/session.ts +23 -4
  79. package/src/ui/index.html +23 -7
@@ -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;
package/src/hub/tasks.ts CHANGED
@@ -97,6 +97,8 @@ export interface TasksDeps {
97
97
  recordScreen?: (record: ScreenRecord) => void;
98
98
  /** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
99
99
  turnFree?: () => boolean;
100
+ /** Coordination preference captured for each newly created task; existing rows keep their initial mode. */
101
+ coordination?: () => boolean;
100
102
  /** Whether a peer's context path for facts is verified in its current session (issue #108). */
101
103
  capable?: (peer: PeerId) => boolean;
102
104
  /** Whether `peer` is between native turns now (issue #107): Codex not busy, Claude stopped since its last tool call. */
@@ -223,10 +225,15 @@ export class Tasks {
223
225
  private readonly knownPlanners = new Set<PeerId>();
224
226
  /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
225
227
  readonly cohorts: Cohorts;
226
- private readonly sweepConfig: TaskSweepConfig;
228
+ private readonly coordinationModes = new Map<number, boolean>();
229
+ private sweepConfig: TaskSweepConfig;
227
230
  private sweeping = false;
228
231
 
229
232
  constructor(private readonly d: TasksDeps) {
233
+ if (d.coordination) {
234
+ const mode = d.coordination();
235
+ for (const task of d.board.list()) this.coordinationModes.set(task.id, mode);
236
+ }
230
237
  const backlog = d.board.list("backlog");
231
238
  for (const [peer, roles] of Object.entries(d.roles ?? {})) {
232
239
  if (!roles.includes("planner")) continue;
@@ -310,13 +317,14 @@ export class Tasks {
310
317
  /** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
311
318
  async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
312
319
  const recorded: { task: number; finding: SweepRecord }[] = [];
313
- if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
320
+ const config = this.sweepConfig;
321
+ if (!config.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
314
322
  this.sweeping = true;
315
323
  try {
316
324
  for (const task of this.d.board.list()) {
317
325
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
318
326
  if (!task.owner && this.personUnassigned(task)) continue;
319
- const finding = nextSweep(task, this.sweepConfig, now, this.waitsFor(task).length === 0);
327
+ const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
320
328
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
321
329
  if (finding.kind === "ready-unassigned") {
322
330
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
@@ -329,7 +337,7 @@ export class Tasks {
329
337
  } else {
330
338
  const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class) });
331
339
  this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
332
- if (admission.owner && this.sweepConfig.auto_reassign && this.sweepAvailable(admission.owner)) {
340
+ if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
333
341
  try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
334
342
  catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
335
343
  }
@@ -338,7 +346,7 @@ export class Tasks {
338
346
  }
339
347
  const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
340
348
  if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
341
- if (finding.step === 3 && finding.kind !== "review-pending" && this.sweepConfig.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
349
+ if (finding.step === 3 && finding.kind !== "review-pending" && config.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
342
350
  const title = this.publicTitle(task);
343
351
  const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
344
352
  // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
@@ -358,7 +366,7 @@ export class Tasks {
358
366
  const candidate = reviewer ? a.reviewer : a.owner;
359
367
  this.passedOver(task, a);
360
368
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
361
- if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
369
+ if (!reviewer && candidate && config.auto_reassign && this.sweepAvailable(candidate)) {
362
370
  try {
363
371
  await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
364
372
  } catch (e) {
@@ -373,6 +381,11 @@ export class Tasks {
373
381
  return recorded;
374
382
  }
375
383
 
384
+ /** A validated snapshot takes effect on the next sweep, never halfway through one. */
385
+ configureSweep(config: TaskSweepConfig): void {
386
+ this.sweepConfig = taskSweepConfig(config);
387
+ }
388
+
376
389
  private sweepAvailable(peer: PeerId): boolean {
377
390
  const bus = this.d.bus;
378
391
  return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !peerEntry(this.d.held?.(), peer) && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
@@ -934,6 +947,10 @@ export class Tasks {
934
947
  const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
935
948
  const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
936
949
  const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
950
+ if (this.d.coordination) {
951
+ const mode = this.d.coordination();
952
+ for (const child of result.children) this.coordinationModes.set(child.id, mode);
953
+ }
937
954
  for (const [index, child] of result.children.entries()) {
938
955
  this.incomingBacklog.add(child.id);
939
956
  if (screening) { this.recordScreen(child.id, "task", verdicts[index], inherited[index]); if (verdicts[index]?.label === "unknown") this.releaseNotice(`task #${child.id}: the PII screen gave no verdict (${verdicts[index]!.miss}); it is handled as a PII task`); }
@@ -1029,6 +1046,7 @@ export class Tasks {
1029
1046
  if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
1030
1047
  if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
1031
1048
  let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
1049
+ if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1032
1050
  created?.();
1033
1051
  if (screening) task = this.screenedTask(task, screened);
1034
1052
  if (goal) this.linkNative(task, goal, by);
@@ -1912,8 +1930,10 @@ export class Tasks {
1912
1930
  */
1913
1931
  private formCohort(task: Task, hits: ReturnType<Tasks["overlapHits"]>): void {
1914
1932
  if (!task.owner || this.isPii(task)) return;
1933
+ const coordinate = this.coordinationModes.get(task.id);
1934
+ const coordinated = this.d.coordination ? hits.filter(hit => this.coordinationModes.get(hit.task.id) === coordinate) : hits;
1915
1935
  const before = this.cohorts.of(task.id)?.revision;
1916
- const joined = this.cohorts.join(task, hits.map((h) => h.task), ownerGen, handedAt);
1936
+ const joined = this.cohorts.join(task, coordinated.map((h) => h.task), ownerGen, handedAt, !this.d.coordination || coordinate === true);
1917
1937
  if (!joined) return;
1918
1938
  const c = joined.cohort;
1919
1939
  if (joined.formed || c.revision !== before) {
@@ -2395,7 +2415,7 @@ export class Tasks {
2395
2415
  const acceptance = previous.history.slice(assignment + 1).some(entry => entry.event === "accepted" && entry.by === peer);
2396
2416
  const boardEvidence = role === "owner" && acceptance;
2397
2417
  const mayHaveReceived = boardEvidence || this.d.bus.lastDeliveredTo(peer).some(matches) || this.d.bus.queueList(peer).some(row => ["dispatching", "accepted", "needs_review", "completed"].includes(row.state) && row.originals.some(matches));
2398
- for (const env of this.d.bus.snapshot(false).queues[peer] ?? []) if (matches(env)) this.d.bus.withdraw(env.id);
2418
+ for (const env of this.d.bus.pendingEnvelopes(peer)) if (matches(env)) this.d.bus.withdraw(env.id);
2399
2419
  if (!mayHaveReceived) return;
2400
2420
  const body = role === "owner"
2401
2421
  ? `Task ${this.publicTitle(next)} is no longer assigned to you${next.owner ? `; its owner is now ${next.owner}` : "; it is unassigned"}. Do not accept or complete it; late calls under your former assignment are refused.`
@@ -2668,7 +2688,7 @@ export class Tasks {
2668
2688
  let n = 0;
2669
2689
  for (const t of this.d.board.list("approved")) {
2670
2690
  if (this.isPii(t)) continue;
2671
- try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2691
+ try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true })) { this.settleMemory(t); n++; } }
2672
2692
  catch { /* fail-open: tried again at the next start */ }
2673
2693
  }
2674
2694
  return n;
package/src/hub/ui.ts CHANGED
@@ -131,7 +131,7 @@ export function startDashboard(options: DashboardOptions) {
131
131
  }
132
132
  }
133
133
  try {
134
- if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
134
+ if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "setting_reload", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
135
135
  return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
136
136
  }
137
137
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
@@ -45,7 +45,7 @@ export class Briefs {
45
45
  constructor(
46
46
  private readonly client: MemoryClient | undefined,
47
47
  private readonly project: string,
48
- private readonly maxItems = 8,
48
+ private readonly maxItems: number | (() => number) = 8,
49
49
  private readonly native?: NativeBrief,
50
50
  /** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
51
51
  private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
@@ -53,18 +53,19 @@ export class Briefs {
53
53
  ) {}
54
54
 
55
55
  async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
56
+ const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
56
57
  const seen = this.seen.get(peer) ?? new Set<string>();
57
58
  this.seen.set(peer, seen);
58
59
  const own: NativeItem[] = [];
59
60
  // A plan fail is task-specific guidance even when a previous task already carried it.
60
- for (const item of priority) if (own.length < this.maxItems && !own.some(existing => existing.id === item.id)) own.push(item);
61
- try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
62
- const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
61
+ for (const item of priority) if (own.length < maximum && !own.some(existing => existing.id === item.id)) own.push(item);
62
+ try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < maximum) own.push(item); } catch { /* fail-open: the store is optional */ }
63
+ const found = this.client && own.length < maximum ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
63
64
  const items: BriefItem[] = [];
64
65
  for (const item of found) {
65
66
  if (seen.has(`#${item.id}`)) continue;
66
67
  items.push(item);
67
- if (own.length + items.length === this.maxItems) break;
68
+ if (own.length + items.length === maximum) break;
68
69
  }
69
70
  if (!own.length && !items.length) return undefined;
70
71
  for (const item of own) seen.add(item.id);
package/src/pi/session.ts CHANGED
@@ -4,13 +4,32 @@ import { randomUUID } from "node:crypto";
4
4
  import { realPath } from "../hub/project.ts";
5
5
  import { atomicPrivateJSON } from "../hub/recovery-store.ts";
6
6
 
7
+ export class PiSessionUnresumable extends Error {
8
+ readonly code = "pi_session_unresumable";
9
+ constructor(readonly reason: "missing" | "outside_managed" | "invalid_header", message: string, options?: ErrorOptions) { super(message, options); }
10
+ }
11
+ export class PiSessionBookmarkMismatch extends Error {
12
+ readonly code = "pi_session_bookmark_mismatch";
13
+ readonly reason = "bookmark_mismatch";
14
+ }
15
+ function sessionReadFailure(error: unknown): never {
16
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new PiSessionUnresumable("missing", (error as Error).message, { cause: error });
17
+ throw error;
18
+ }
19
+
7
20
  /** One validation boundary for ordinary starts and explicit preserved-history selection. */
8
21
  export function validatePiSession(stateDir: string, cwd: string, sessionFile: string, sessionId?: string): { sessionFile: string; sessionId: string } {
9
- const file = realPath(sessionFile), sessions = realPath(join(stateDir, "pi-sessions"));
22
+ let file: string, sessions: string;
23
+ try { file = realPath(sessionFile); sessions = realPath(join(stateDir, "pi-sessions")); }
24
+ catch (error) { return sessionReadFailure(error); }
10
25
  const rel = relative(sessions, file);
11
- if (!rel || rel.startsWith("..") || resolve(sessions, rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
12
- const header = JSON.parse(readFileSync(file, "utf8").split("\n", 1)[0]!);
13
- if (header.type !== "session" || typeof header.id !== "string" || !header.id || typeof header.cwd !== "string" || realPath(header.cwd) !== realPath(cwd) || (sessionId && header.id !== sessionId)) throw new Error("Pi session header does not match the requested project/session");
26
+ if (!rel || rel.startsWith("..") || resolve(sessions, rel) !== file) throw new PiSessionUnresumable("outside_managed", "Pi session file is outside the managed project session directory");
27
+ let first: string;
28
+ try { first = readFileSync(file, "utf8").split("\n", 1)[0]!; } catch (error) { return sessionReadFailure(error); }
29
+ let header: { type?: unknown; id?: unknown; cwd?: unknown } | null;
30
+ try { header = JSON.parse(first); } catch (error) { throw new PiSessionUnresumable("invalid_header", (error as Error).message, { cause: error }); }
31
+ if (!header || header.type !== "session" || typeof header.id !== "string" || !header.id || typeof header.cwd !== "string" || realPath(header.cwd) !== realPath(cwd)) throw new PiSessionUnresumable("invalid_header", "Pi session header does not match the requested project/session");
32
+ if (sessionId && header.id !== sessionId) throw new PiSessionBookmarkMismatch("Pi continuity bookmark does not match the preserved session");
14
33
  return { sessionFile: file, sessionId: header.id };
15
34
  }
16
35
 
package/src/ui/index.html CHANGED
@@ -319,16 +319,19 @@ async function previewSetting(row, value, out, control) {
319
319
  function settingRow(row, raise) {
320
320
  const node = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), id = 'setting-' + row.key.replaceAll('.', '-');
321
321
  const title = el('label', row.label, 'label'); title.htmlFor = id;
322
+ const fileValue = row.fileValue !== undefined ? row.fileValue : row.pending !== undefined ? row.pending : row.value;
322
323
  head.append(title, badge(settingText(row.value), row.group === 'Permissions' && row.value !== null && row.value !== 'ask' ? 'warn' : ''));
323
- node.append(head, el('p', row.key + ' / from ' + row.source + ' / ' + (row.applies === 'live' ? 'applies at once' : row.applies === 'peer start' ? "applies at the peer's next start" : 'read at hub start') + ' / written to ' + row.file, 'meta'));
324
- if (row.pending !== undefined) node.append(el('p', 'Pending restart: ' + settingText(row.pending) + ' at the next ' + (row.applies === 'peer start' ? 'peer' : 'hub') + ' start.', 'meta'));
324
+ node.append(head, el('p', row.key + ' / from ' + row.source + ' / ' + (row.applies === 'live' ? 'applies at once' : row.applies === 'next use' ? 'applies at next use' : row.applies === 'restart' ? 'applies after restart' : row.applies === 'peer start' ? "applies at the peer's next start" : 'read at hub start') + ' / written to ' + row.file, 'meta'));
325
+ node.append(el('p', 'Running: ' + settingText(row.value) + ' / File: ' + settingText(fileValue), 'meta'));
326
+ if (['live', 'next use', 'restart'].includes(row.applies)) node.append(el('p', 'Apply: ' + row.applies + (row.restartReason ? ' / ' + row.restartReason : ''), 'meta'));
327
+ if (row.pending !== undefined) node.append(el('p', ['hub start', 'peer start'].includes(row.applies) ? 'Pending restart: ' + settingText(row.pending) + ' at the next ' + (row.applies === 'peer start' ? 'peer' : 'hub') + ' start.' : 'Pending ' + row.applies + ': ' + settingText(row.pending), 'meta'));
325
328
  if (row.hint) node.append(el('p', 'Bounds: ' + row.hint, 'meta'));
326
329
  if (row.note) node.append(el('p', row.note, 'meta'));
327
330
  if (row.risk === 'raises' && !raise) node.append(el('p', 'Raising this needs a settings session: run ahub ui --settings.' + (row.floor === undefined ? '' : ' Setting it to ' + row.floor + ' works here.'), 'meta'));
328
331
  const actions = el('div', undefined, 'actions'), stored = row.file !== 'this hub run';
329
332
  let read, confirm, force;
330
333
  if (row.type === 'roles') {
331
- const current = Array.isArray(row.pending) ? row.pending : Array.isArray(row.value) ? row.value : [];
334
+ const current = Array.isArray(fileValue) ? fileValue : [];
332
335
  const choices = [0, 1].map(index => {
333
336
  const select = el('select'); select.id = index ? id + '-second' : id;
334
337
  if (index) select.setAttribute('aria-label', row.label + ': second optional role');
@@ -338,15 +341,15 @@ function settingRow(row, raise) {
338
341
  select.value = current[index] || ''; actions.append(select); return select;
339
342
  });
340
343
  read = () => choices.map(select => select.value).filter(Boolean);
341
- } else if (row.type === 'integer') {
342
- const input=el('input');input.type='number';input.id=id;input.step='1';input.min=String(row.min ?? 0);input.max=String(row.max ?? Number.MAX_SAFE_INTEGER);const current=row.pending!==undefined ? row.pending : row.value;input.value=current===null ? '' : String(current);input.placeholder='Empty inherits';
344
+ } else if (row.type === 'integer' || row.type === 'number') {
345
+ const input=el('input');input.type='number';input.id=id;input.step=row.type === 'number' ? 'any' : '1';input.min=String(row.min ?? 0);input.max=String(row.max ?? Number.MAX_SAFE_INTEGER);const current=fileValue;input.value=current===null ? '' : String(current);input.placeholder='Empty inherits';
343
346
  read=()=>input.value==='' ? null : Number(input.value);actions.append(input);
344
347
  } else if (row.type === 'peers') {
345
- const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(row.value) ? row.value.join(', ') : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
348
+ const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(fileValue) ? fileValue.join(', ') : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
346
349
  read = () => { const list = input.value.split(',').map((value) => value.trim()).filter(Boolean); return list.length ? list : null; };
347
350
  actions.append(input);
348
351
  } else {
349
- const select = el('select'), values = row.type === 'boolean' ? ['true', 'false'] : (row.values || []), current = row.value === null ? '' : String(row.value); select.id = id;
352
+ const select = el('select'), values = row.type === 'boolean' ? ['true', 'false'] : (row.values || []), current = fileValue === null ? '' : String(fileValue); select.id = id;
350
353
  if (current && !values.includes(current)) { const option = el('option', current + ' (now)'); option.value = current; option.disabled = true; select.append(option); }
351
354
  for (const value of values) { const option = el('option', value); option.value = value; select.append(option); }
352
355
  if (stored) { const option = el('option', 'inherit (remove the machine-local value)'); option.value = INHERIT; select.append(option); }
@@ -391,6 +394,7 @@ function renderSettings(settings, roster = []) {
391
394
  // are rebuilt only when their own data changed, so a value being typed in one row survives a save of another.
392
395
  update('settings', settings.rows.map((row) => row.key), (target) => {
393
396
  settingSlots.clear();
397
+ const reload = el('div'); settingSlots.set('$reload', { node: reload, signature: null }); target.append(reload);
394
398
  const undo = el('div'); settingSlots.set('', { node: undo, signature: null }); target.append(undo);
395
399
  for (const group of SETTING_GROUPS) {
396
400
  const rows = settings.rows.filter((row) => row.group === group);
@@ -402,6 +406,18 @@ function renderSettings(settings, roster = []) {
402
406
  target.append(box);
403
407
  }
404
408
  });
409
+ const reloadSlot = settingSlots.get('$reload'), reloadSupported = Array.isArray(settings.pendingRestart);
410
+ const reloadSignature = JSON.stringify([settings.pendingRestart, managerMode && !snapshotValid]);
411
+ if (reloadSlot && reloadSlot.signature !== reloadSignature) {
412
+ reloadSlot.signature = reloadSignature; reloadSlot.node.replaceChildren();
413
+ if (reloadSupported) {
414
+ reloadSlot.node.append(button('Reload configuration', {action:'setting_reload'}));
415
+ if (settings.pendingRestart.length) {
416
+ reloadSlot.node.append(el('p', 'Pending restart', 'label'));
417
+ for (const pending of settings.pendingRestart) reloadSlot.node.append(el('p', pending.key + ': ' + pending.reason, 'meta'));
418
+ }
419
+ }
420
+ }
405
421
  const undoSlot = settingSlots.get(''), undoKey = settings.undo || '';
406
422
  if (undoSlot && undoSlot.signature !== undoKey) {
407
423
  undoSlot.signature = undoKey; undoSlot.node.replaceChildren();