@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.
- package/CHANGELOG.md +13 -1
- package/README.md +13 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +10 -7
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +114 -22
- package/docs/quickstart.md +10 -6
- package/docs/security.md +12 -8
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +191 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1296 -26
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -75
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +345 -194
- package/src/cli/upgrade.ts +276 -25
- package/src/hub/bus.ts +11 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +559 -170
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +28 -8
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- 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
|
|
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
|
-
|
|
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,
|
|
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 &&
|
|
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" &&
|
|
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 &&
|
|
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,
|
|
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);
|
package/src/memory/brief.ts
CHANGED
|
@@ -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 <
|
|
61
|
-
try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length <
|
|
62
|
-
const found = this.client && own.length <
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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(
|
|
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 =
|
|
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();
|