@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/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) {
@@ -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();