@staix/agent-hub 0.12.30 → 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 +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -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 +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- 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/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- 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 -74
- 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 +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -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/control-client.ts +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -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/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
5
|
+
import { realPath } from "./project.ts";
|
|
6
|
+
import { atomicPrivateJSON, operationIdPattern } from "./recovery-store.ts";
|
|
7
|
+
|
|
8
|
+
export const RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS = 3000;
|
|
9
|
+
const MAX_BOOKMARK_BYTES = 16 * 1024;
|
|
10
|
+
const BOOKMARK = "recovery-continuity.json", LOCK = "recovery-continuity-lock.db";
|
|
11
|
+
|
|
12
|
+
export interface RecoveryContinuityBookmark {
|
|
13
|
+
schema: 1;
|
|
14
|
+
operationId: string;
|
|
15
|
+
projectId: string;
|
|
16
|
+
projectRoot: string;
|
|
17
|
+
at: number;
|
|
18
|
+
pi: { sessionId: string; sessionFile?: string; backend?: "auto" | "dgx" | "mlx"; model?: string };
|
|
19
|
+
}
|
|
20
|
+
export interface RecoveryContinuityExpected { projectId: string; projectRoot: string; operationId?: string }
|
|
21
|
+
export type ContinuityRetirementReason = "authenticated_session_changed" | "resume_unavailable" | "recovery_released";
|
|
22
|
+
export type RecoveryContinuityReading =
|
|
23
|
+
| { state: "absent" }
|
|
24
|
+
| { state: "invalid"; reason: "unavailable" | "invalid_shape" | "invalid_path" | "identity_mismatch" | "oversized" }
|
|
25
|
+
| { state: "ready"; bookmark: RecoveryContinuityBookmark; digest: string };
|
|
26
|
+
|
|
27
|
+
const unavailable = (): never => { throw new Error("recovery continuity metadata unavailable or invalid"); };
|
|
28
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
const keys = (value: Record<string, unknown>, allowed: readonly string[]): boolean => Object.keys(value).every(key => allowed.includes(key));
|
|
30
|
+
const text = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
|
|
31
|
+
const id = (value: unknown): value is string => text(value, 256) && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value);
|
|
32
|
+
const absolute = (value: unknown): value is string => text(value, 4096) && isAbsolute(value) && normalize(value) === value;
|
|
33
|
+
function canonicalRoot(value: unknown): string {
|
|
34
|
+
if (!absolute(value)) return unavailable();
|
|
35
|
+
const root = realPath(value);
|
|
36
|
+
if (!lstatSync(root).isDirectory()) return unavailable();
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
function checked(value: unknown): RecoveryContinuityBookmark {
|
|
40
|
+
if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", "pi"]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
|
|
41
|
+
const pi = value.pi;
|
|
42
|
+
if (!record(pi) || !keys(pi, ["sessionId", "sessionFile", "backend", "model"]) || !id(pi.sessionId) || pi.sessionFile !== undefined && !absolute(pi.sessionFile) || pi.backend !== undefined && !["auto", "dgx", "mlx"].includes(String(pi.backend)) || pi.model !== undefined && !text(pi.model, 4096)) return unavailable();
|
|
43
|
+
const projectRoot = canonicalRoot(value.projectRoot);
|
|
44
|
+
return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number,
|
|
45
|
+
pi: { sessionId: pi.sessionId, ...(pi.sessionFile === undefined ? {} : { sessionFile: pi.sessionFile as string }), ...(pi.backend === undefined ? {} : { backend: pi.backend as "auto" | "dgx" | "mlx" }), ...(pi.model === undefined ? {} : { model: pi.model as string }) } };
|
|
46
|
+
}
|
|
47
|
+
function stateDirectory(stateDir: string): void {
|
|
48
|
+
if (!absolute(stateDir)) return unavailable();
|
|
49
|
+
const stat = lstatSync(stateDir);
|
|
50
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable();
|
|
51
|
+
}
|
|
52
|
+
function privateFile(path: string, missing = false): void {
|
|
53
|
+
try {
|
|
54
|
+
const stat = lstatSync(path);
|
|
55
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.mode & 0o077) return unavailable();
|
|
56
|
+
} catch (error) { if (!missing || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
57
|
+
}
|
|
58
|
+
const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
|
|
59
|
+
|
|
60
|
+
/** Read one bounded project-local file; invalid evidence is deliberately distinct from no bookmark. */
|
|
61
|
+
export function readRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected): RecoveryContinuityReading {
|
|
62
|
+
let fd: number | undefined;
|
|
63
|
+
let expectedRoot: string;
|
|
64
|
+
try { expectedRoot = canonicalRoot(expected.projectRoot); }
|
|
65
|
+
catch { return { state: "invalid", reason: "identity_mismatch" }; }
|
|
66
|
+
try {
|
|
67
|
+
stateDirectory(stateDir);
|
|
68
|
+
const file = join(stateDir, BOOKMARK);
|
|
69
|
+
privateFile(file);
|
|
70
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
71
|
+
const stat = fstatSync(fd);
|
|
72
|
+
if (!stat.isFile() || stat.mode & 0o077) return { state: "invalid", reason: "invalid_path" };
|
|
73
|
+
if (stat.size > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
|
|
74
|
+
const buffer = Buffer.alloc(MAX_BOOKMARK_BYTES + 1);
|
|
75
|
+
let length = 0;
|
|
76
|
+
while (length < buffer.length) { const n = readSync(fd, buffer, length, buffer.length - length, null); if (!n) break; length += n; }
|
|
77
|
+
if (length > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
|
|
78
|
+
const bytes = buffer.subarray(0, length);
|
|
79
|
+
let bookmark: RecoveryContinuityBookmark;
|
|
80
|
+
try {
|
|
81
|
+
const raw: unknown = JSON.parse(bytes.toString("utf8"));
|
|
82
|
+
bookmark = checked(raw);
|
|
83
|
+
if (!record(raw) || raw.projectRoot !== bookmark.projectRoot) return { state: "invalid", reason: "invalid_path" };
|
|
84
|
+
} catch { return { state: "invalid", reason: "invalid_shape" }; }
|
|
85
|
+
if (!id(expected.projectId) || expected.operationId !== undefined && !operationIdPattern.test(expected.operationId) || bookmark.projectId !== expected.projectId || bookmark.projectRoot !== expectedRoot || expected.operationId !== undefined && bookmark.operationId !== expected.operationId) return { state: "invalid", reason: "identity_mismatch" };
|
|
86
|
+
return { state: "ready", bookmark, digest: digest(bytes) };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "invalid", reason: "unavailable" };
|
|
89
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A crash releases SQLite's lock; no lockfile ownership or stale-process inference is needed. */
|
|
93
|
+
function withWriter<T>(stateDir: string, action: () => T): T {
|
|
94
|
+
stateDirectory(stateDir);
|
|
95
|
+
const file = join(stateDir, LOCK);
|
|
96
|
+
privateFile(file, true);
|
|
97
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) privateFile(file + suffix, true);
|
|
98
|
+
const db = new Database(file, { create: true });
|
|
99
|
+
db.run(`PRAGMA busy_timeout = ${RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS}`);
|
|
100
|
+
try {
|
|
101
|
+
chmodSync(file, 0o600);
|
|
102
|
+
db.run("BEGIN IMMEDIATE");
|
|
103
|
+
try { return action(); } finally { db.run("ROLLBACK"); }
|
|
104
|
+
} finally { db.close(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Called only after the coordinator confirmed its target stopped and archived that operation's snapshot. */
|
|
108
|
+
export function writeRecoveryContinuity(stateDir: string, value: RecoveryContinuityBookmark): { digest: string } {
|
|
109
|
+
const bookmark = checked(value), bytes = Buffer.from(JSON.stringify(bookmark, null, 2) + "\n");
|
|
110
|
+
if (bytes.length > MAX_BOOKMARK_BYTES) return unavailable();
|
|
111
|
+
return withWriter(stateDir, () => {
|
|
112
|
+
privateFile(join(stateDir, BOOKMARK), true);
|
|
113
|
+
atomicPrivateJSON(join(stateDir, BOOKMARK), bookmark);
|
|
114
|
+
return { digest: digest(bytes) };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Consume only the exact bookmark whose recorded session has authenticated; stale receipts retain newer data. */
|
|
119
|
+
export function consumeRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected & { operationId: string; digest: string }): boolean {
|
|
120
|
+
if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return false;
|
|
121
|
+
return withWriter(stateDir, () => {
|
|
122
|
+
const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
|
|
123
|
+
if (current.state === "absent") return false;
|
|
124
|
+
if (current.state === "invalid") return unavailable();
|
|
125
|
+
if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return false;
|
|
126
|
+
unlinkSync(join(stateDir, BOOKMARK));
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Preserve superseded evidence privately; only a matching project/operation/digest may retire the active file. */
|
|
132
|
+
export function retireRecoveryContinuity(
|
|
133
|
+
stateDir: string,
|
|
134
|
+
expected: RecoveryContinuityExpected & { operationId: string; digest: string },
|
|
135
|
+
reason: ContinuityRetirementReason,
|
|
136
|
+
): { retired: boolean; archive?: string } {
|
|
137
|
+
if (!["authenticated_session_changed", "resume_unavailable", "recovery_released"].includes(reason)) return unavailable();
|
|
138
|
+
if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return { retired: false };
|
|
139
|
+
return withWriter(stateDir, () => {
|
|
140
|
+
const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
|
|
141
|
+
if (current.state === "absent") return { retired: false };
|
|
142
|
+
if (current.state === "invalid") return unavailable();
|
|
143
|
+
if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return { retired: false };
|
|
144
|
+
// A unique destination preserves an earlier retirement even when the same bookmark was explicitly restored.
|
|
145
|
+
const archive = join(stateDir, `recovery-continuity.retired.${reason}.${current.digest}.${randomUUID()}.json`);
|
|
146
|
+
try { lstatSync(archive); return unavailable(); }
|
|
147
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
148
|
+
renameSync(join(stateDir, BOOKMARK), archive);
|
|
149
|
+
chmodSync(archive, 0o600);
|
|
150
|
+
return { retired: true, archive };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -69,15 +69,21 @@ export function assertLifecycleAvailable(): void {
|
|
|
69
69
|
* lacks dispose, refuses abort and resume on a lapsed hold and shows no next actions: then the running release is
|
|
70
70
|
* named, which does all of that in-process (its resume still runs the operation's own runner).
|
|
71
71
|
*/
|
|
72
|
-
export
|
|
73
|
-
|
|
72
|
+
export type RecoveryAction = "status" | "resume" | "abort" | "dispose" | "reattach";
|
|
73
|
+
export function supportsAction(sourceRoot: string, action: RecoveryAction): boolean {
|
|
74
|
+
if (!coordinatorCurrent(sourceRoot)) return false;
|
|
75
|
+
if (action !== "reattach") return true;
|
|
76
|
+
try { return readFileSync(join(sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export async function reattachRecovery"); } catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags = ""): string {
|
|
79
|
+
const entry = !op.sourceRoot ? undefined : supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
|
|
74
80
|
const cli = !entry ? "ahub" : /^[\w./@+-]+$/.test(entry) ? `bun ${entry}` : `bun '${entry.replace(/'/g, `'\\''`)}'`;
|
|
75
81
|
return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/** #272: the command above as an argv for `bun`, which a screen runs instead of printing; with no coordinator of its own, this release's. */
|
|
79
|
-
export function recoveryArgv(op: { id: string; sourceRoot?: string }, action:
|
|
80
|
-
const entry = op.sourceRoot &&
|
|
85
|
+
export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags: string[] = []): string[] {
|
|
86
|
+
const entry = op.sourceRoot && supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
|
|
81
87
|
return [entry, "recovery", action, op.id, ...flags];
|
|
82
88
|
}
|
|
83
89
|
|
package/src/hub/routing.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
4
|
+
import { isGitHubRepository, specRefs } from "./spec-read.ts";
|
|
5
|
+
|
|
6
|
+
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; }
|
|
4
7
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
5
8
|
|
|
6
9
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
@@ -231,7 +234,10 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
|
|
|
231
234
|
/** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
|
|
232
235
|
export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
|
|
233
236
|
|
|
237
|
+
export type RoutingRefusalCode = "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
|
|
238
|
+
export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
|
|
234
239
|
export interface Assignment {
|
|
240
|
+
refusals?: RoutingRefusal[];
|
|
235
241
|
owner?: PeerId;
|
|
236
242
|
reviewer?: PeerId;
|
|
237
243
|
/** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
|
|
@@ -248,10 +254,12 @@ export interface Assignment {
|
|
|
248
254
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
249
255
|
*/
|
|
250
256
|
export function assign(
|
|
251
|
-
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state">>,
|
|
257
|
+
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
|
|
252
258
|
states: Record<PeerId, PeerState>,
|
|
253
259
|
routing: Routing,
|
|
254
260
|
opts: {
|
|
261
|
+
capabilities?: Record<PeerId, PeerCapabilities>;
|
|
262
|
+
repository?: string;
|
|
255
263
|
exclude?: PeerId[];
|
|
256
264
|
/** Owner admission limits do not remove otherwise eligible reviewers. */
|
|
257
265
|
excludeOwners?: PeerId[];
|
|
@@ -280,6 +288,7 @@ export function assign(
|
|
|
280
288
|
roles?: Record<string, string[]>;
|
|
281
289
|
} = {},
|
|
282
290
|
): Assignment {
|
|
291
|
+
if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
|
|
283
292
|
if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
|
|
284
293
|
const policy = routing.classes[task.class];
|
|
285
294
|
const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
|
|
@@ -292,29 +301,39 @@ export function assign(
|
|
|
292
301
|
if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
|
|
293
302
|
const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
|
|
294
303
|
|
|
304
|
+
let needsSpec = false;
|
|
305
|
+
if (!pii) try { needsSpec = specRefs({ title: task.title ?? "", detail: task.detail ?? "", refs: task.refs ?? {}, external: task.external }, opts.repository ?? "", { bareTitle: isGitHubRepository(opts.repository) }).length > 0; }
|
|
306
|
+
catch { needsSpec = true; } // an invalid/foreign reference never grants a sandboxed peer spec access
|
|
307
|
+
const needsTests = ["implement", "test", "bulk_edit"].includes(task.class);
|
|
308
|
+
const refusals = new Map<string, RoutingRefusal>();
|
|
295
309
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
296
|
-
|
|
310
|
+
const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
|
|
311
|
+
if (role === "owner" && opts.capabilities && !pii) {
|
|
312
|
+
const caps = peerEntry(opts.capabilities, peer);
|
|
313
|
+
if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
|
|
314
|
+
if (needsSpec && !caps?.spec) return deny("spec", `missing spec: ${caps?.specReason ?? "capability unavailable"}`);
|
|
315
|
+
}
|
|
316
|
+
if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
|
|
297
317
|
const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
|
|
298
|
-
if (opts.pullOnly?.includes(peer) && named !== peer) return "pull-only: takes work when named or when it claims it";
|
|
299
|
-
if (!Object.hasOwn(states, peer)) return "not attached";
|
|
300
|
-
if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
|
|
301
|
-
if (role === "owner" && opts.excludeOwners?.includes(peer)) return "owner admission: WIP or delivery hold";
|
|
302
|
-
if (states[peer] === "offline") return "offline";
|
|
303
|
-
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
|
|
318
|
+
if (opts.pullOnly?.includes(peer) && named !== peer) return deny("pull-only", "pull-only: takes work when named or when it claims it");
|
|
319
|
+
if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
|
|
320
|
+
if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
|
|
321
|
+
if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
|
|
322
|
+
if (states[peer] === "offline") return deny("offline", "offline");
|
|
323
|
+
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
|
|
304
324
|
const failing = peerEntry(opts.failing, peer);
|
|
305
|
-
if (failing) return `failing: ${failing}
|
|
306
|
-
if (pii && peer !== LOCAL) return "pii: on-prem peers only";
|
|
307
|
-
if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
|
|
308
|
-
if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
|
|
325
|
+
if (failing) return deny("failing", `failing: ${failing}`);
|
|
326
|
+
if (pii && peer !== LOCAL) return deny("pii", "pii: on-prem peers only");
|
|
327
|
+
if (peer === LOCAL && policy?.local_allowed === false) return deny("policy", "local_allowed = false for this class");
|
|
328
|
+
if (peer === PI && policy?.local_allowed === false) return deny("policy", "local_allowed = false also excludes pi for this class");
|
|
309
329
|
if (peer === PI && task.signals.includes("long_context")) {
|
|
310
330
|
const backend = policy?.pi_backend ?? "dgx";
|
|
311
331
|
const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
|
|
312
|
-
if (routing.signals.long_context_tokens > limit) return `pi ${backend} capability limit ${limit} tokens
|
|
332
|
+
if (routing.signals.long_context_tokens > limit) return deny("policy", `pi ${backend} capability limit ${limit} tokens`);
|
|
313
333
|
}
|
|
314
|
-
if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return "long_context: skip local";
|
|
334
|
+
if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
|
|
315
335
|
return undefined;
|
|
316
336
|
};
|
|
317
|
-
|
|
318
337
|
/** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
|
|
319
338
|
const drain = (p: PeerId): number | undefined => {
|
|
320
339
|
const q = peerEntry(opts.quota, p);
|
|
@@ -396,7 +415,7 @@ export function assign(
|
|
|
396
415
|
const piBackend = policy?.pi_backend;
|
|
397
416
|
if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
398
417
|
if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
399
|
-
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), trace };
|
|
418
|
+
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
400
419
|
}
|
|
401
420
|
|
|
402
421
|
/**
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
6
7
|
|
|
7
8
|
export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
|
|
8
9
|
export type SdlcBaseStage = typeof SDLC_STAGES[number];
|
|
@@ -219,7 +220,10 @@ export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutpu
|
|
|
219
220
|
/** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
|
|
220
221
|
export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
|
|
221
222
|
const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
|
|
222
|
-
|
|
223
|
+
let child: ReturnType<typeof spawn>;
|
|
224
|
+
try {
|
|
225
|
+
child = spawn(hostExecutable("gh", cwd ?? process.cwd()), args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
226
|
+
} catch { resolve({ code: 1, stdout: "" }); return; }
|
|
223
227
|
let stdout = "", failed = false;
|
|
224
228
|
const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
225
229
|
const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
1
|
+
import { PLANNER_PEERS, SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
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,20 +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
|
-
...
|
|
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] })),
|
|
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"] },
|
|
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] })),
|
|
50
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"] },
|
|
51
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] })),
|
|
52
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] })),
|
|
53
|
-
...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at
|
|
54
|
-
...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: "
|
|
55
|
-
{ 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"] },
|
|
56
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"] },
|
|
57
61
|
...CLASSES.flatMap(perClass),
|
|
58
|
-
{ key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "
|
|
59
|
-
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "
|
|
60
|
-
{ key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "
|
|
61
|
-
{ 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"] },
|
|
62
66
|
];
|
|
63
67
|
|
|
64
68
|
/**
|
|
@@ -82,8 +86,8 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
82
86
|
if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
|
|
83
87
|
return null;
|
|
84
88
|
}
|
|
85
|
-
if (def.type === "integer") {
|
|
86
|
-
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}`);
|
|
87
91
|
return raw;
|
|
88
92
|
}
|
|
89
93
|
if (def.type === "boolean") {
|
|
@@ -107,6 +111,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
107
111
|
/** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
|
|
108
112
|
export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
109
113
|
if (text === "inherit") return null;
|
|
114
|
+
if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
|
|
110
115
|
if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
|
|
111
116
|
if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
|
|
112
117
|
if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
@@ -162,14 +167,7 @@ function refuseTracked(cwd: string, file: StoredFile): void {
|
|
|
162
167
|
const tracked = configTracked(cwd, file);
|
|
163
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`);
|
|
164
169
|
// Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
|
|
165
|
-
if (tracked === undefined &&
|
|
166
|
-
}
|
|
167
|
-
/** Whether this directory or one above it holds a `.git`: a repository git should have been able to answer for. */
|
|
168
|
-
function insideRepository(dir: string): boolean {
|
|
169
|
-
for (let at = dir; ; at = dirname(at)) {
|
|
170
|
-
if (existsSync(join(at, ".git"))) return true;
|
|
171
|
-
if (dirname(at) === at) return false;
|
|
172
|
-
}
|
|
170
|
+
if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
|
|
173
171
|
}
|
|
174
172
|
/** Keeps the one previous version, then replaces (or removes) the file. */
|
|
175
173
|
function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
|
|
@@ -262,7 +260,7 @@ function checkConfigText(cwd: string, text: string | null, validate: (scratch: s
|
|
|
262
260
|
* Sets (or with null removes) one key of `.agenthub/routing.local.toml`; the real parser reads the result first, and
|
|
263
261
|
* `check` may refuse the policy it yields. Undefined when the overlay already holds that value: nothing is written.
|
|
264
262
|
*/
|
|
265
|
-
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 {
|
|
266
264
|
const file = join(cwd, ".agenthub", OVERLAY_FILE);
|
|
267
265
|
const before = readText(file);
|
|
268
266
|
const current = before === null ? {} : parseOverlay(before);
|
|
@@ -270,7 +268,7 @@ export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingD
|
|
|
270
268
|
if (value === null ? held === undefined : same(held, value)) return undefined;
|
|
271
269
|
const overlay = routingCandidate(current, def, value);
|
|
272
270
|
const routing = parseRouting(routingText(cwd), overlay);
|
|
273
|
-
check?.(routing);
|
|
271
|
+
check?.(routing, overlay);
|
|
274
272
|
const empty = !overlay.stay_switch && !Object.keys(overlay.classes ?? {}).length;
|
|
275
273
|
store(cwd, stateDir, OVERLAY_FILE, def.key, before, empty ? null : overlayToml(overlay));
|
|
276
274
|
return routing;
|
|
@@ -301,11 +299,13 @@ export function readOverlay(cwd: string): RoutingOverlay {
|
|
|
301
299
|
* Puts back the one previous version of the file the last settings write changed. Refused when the file changed since
|
|
302
300
|
* that write (a hand edit would be lost), when the loaders no longer accept that version, or when there is nothing to undo.
|
|
303
301
|
*/
|
|
304
|
-
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 } {
|
|
305
304
|
const raw = readText(undoFile(stateDir));
|
|
306
305
|
if (raw === null) throw new Error("nothing to undo");
|
|
307
306
|
let record: UndoRecord;
|
|
308
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");
|
|
309
309
|
if (record.file !== CONFIG_LOCAL && record.file !== OVERLAY_FILE) throw new Error("nothing to undo");
|
|
310
310
|
const path = join(cwd, ".agenthub", record.file);
|
|
311
311
|
if (digest(readText(path)) !== record.written) throw new Error(`.agenthub/${record.file} changed since the last settings write; nothing was undone`);
|
|
@@ -313,10 +313,12 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
|
|
|
313
313
|
refuseTracked(cwd, record.file);
|
|
314
314
|
if (record.file === OVERLAY_FILE) {
|
|
315
315
|
// The parser always reads what is put back; the caller's check is on top of it.
|
|
316
|
-
const
|
|
317
|
-
|
|
316
|
+
const overlay = record.previous === null ? undefined : parseOverlay(record.previous);
|
|
317
|
+
const restored = parseRouting(routingText(cwd), overlay);
|
|
318
|
+
checkRouting?.(restored, overlay);
|
|
318
319
|
}
|
|
319
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");
|
|
320
322
|
replaceFile(path, record.file, record.previous);
|
|
321
323
|
rmSync(undoFile(stateDir), { force: true });
|
|
322
324
|
return { key: String(record.key), file: record.file };
|
|
@@ -334,13 +336,23 @@ export function restoredValue(cwd: string, stateDir: string, def: SettingDef, in
|
|
|
334
336
|
const parse = (text: string | null): unknown => { try { return text === null ? {} : JSON.parse(text); } catch { return {}; } };
|
|
335
337
|
let local: unknown = {};
|
|
336
338
|
if (!inherit) {
|
|
337
|
-
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; }
|
|
338
343
|
}
|
|
339
344
|
return valueAt(local, def.path) ?? valueAt(parse(readText(join(cwd, ".agenthub", "config.json"))), def.path);
|
|
340
345
|
}
|
|
341
346
|
/** The key the next undo would put back, if any. */
|
|
342
347
|
export function pendingUndo(stateDir: string): string | undefined {
|
|
343
|
-
|
|
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; }
|
|
344
356
|
}
|
|
345
357
|
|
|
346
358
|
/** The loader's line saying it ignored this setting's field in the machine-local file, if it did (config trust, #17). */
|
|
@@ -354,9 +366,12 @@ export interface SettingRow {
|
|
|
354
366
|
key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
|
|
355
367
|
/** The file a write goes to, or "this hub run". */
|
|
356
368
|
file: string;
|
|
357
|
-
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;
|
|
358
370
|
/** What is in force now; null when nothing sets it. */
|
|
359
371
|
value: SettingValue;
|
|
372
|
+
/** Optional for replies from pre-#439 daemons; current projections always supply it. */
|
|
373
|
+
fileValue?: SettingValue;
|
|
374
|
+
restartReason?: string;
|
|
360
375
|
/** Which file the value in force comes from. */
|
|
361
376
|
source: string;
|
|
362
377
|
/** The stored value the next hub start reads, when it differs from the one in force. */
|
|
@@ -389,23 +404,23 @@ export function settingRows(input: {
|
|
|
389
404
|
const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
|
|
390
405
|
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
391
406
|
return SETTINGS.map((def): SettingRow => {
|
|
392
|
-
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 }),
|
|
393
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)!) } : {}) };
|
|
394
409
|
if (def.store === "runtime") {
|
|
395
410
|
const mode = input.mode(def.path[0]!);
|
|
396
|
-
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 } : {}) };
|
|
397
412
|
}
|
|
398
413
|
if (def.store === "routing") {
|
|
399
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]!]);
|
|
400
|
-
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"),
|
|
401
416
|
...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
|
|
402
417
|
}
|
|
403
|
-
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." }; }
|
|
404
419
|
const value = scalar(input.running(def.path));
|
|
405
420
|
const stored = input.next ? scalar(input.next(def.path)) : value;
|
|
406
421
|
const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
|
|
407
422
|
const dropped = ignoredLine(input.ignored, def);
|
|
408
|
-
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 }),
|
|
409
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}` } : {}) };
|
|
410
425
|
});
|
|
411
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
|
+
}
|