@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.
- package/CHANGELOG.md +20 -1
- package/README.md +14 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +13 -8
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -3
- 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 +141 -25
- package/docs/quickstart.md +10 -6
- package/docs/security.md +18 -9
- 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 +211 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verified.json +24 -16
- 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/reset.ts +13 -1
- 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 +227 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +360 -199
- package/src/cli/upgrade.ts +278 -25
- package/src/hub/bus.ts +144 -14
- 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/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +577 -179
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- 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/restart.ts +12 -1
- 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 +29 -9
- 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/settings.ts
CHANGED
|
@@ -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
|
-
/** `
|
|
31
|
-
applies: "live" | "
|
|
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
|
-
...
|
|
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: "
|
|
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
|
|
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: "
|
|
56
|
-
{ key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "
|
|
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: "
|
|
60
|
-
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "
|
|
61
|
-
{ key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "
|
|
62
|
-
{ key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "
|
|
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 &&
|
|
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
|
|
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
|
|
318
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
+
}
|
package/src/hub/supervision.ts
CHANGED
|
@@ -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 <
|
|
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
|
|
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) {
|
|
@@ -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.
|
|
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);
|
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();
|