@staix/agent-hub 0.12.31 → 0.12.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/README.md +13 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +10 -7
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +114 -22
- package/docs/quickstart.md +10 -6
- package/docs/security.md +12 -8
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +191 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1296 -26
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -75
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +345 -194
- package/src/cli/upgrade.ts +276 -25
- package/src/hub/bus.ts +11 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +559 -170
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +28 -8
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +23 -7
package/src/hub/events.ts
CHANGED
|
@@ -14,9 +14,9 @@ export const EVENTS_SCHEMA = 1;
|
|
|
14
14
|
*/
|
|
15
15
|
export type HubEvent =
|
|
16
16
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
17
|
-
/** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
|
|
18
17
|
| { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
|
|
19
|
-
|
|
18
|
+
/** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
|
|
19
|
+
| { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean; reason?: string }
|
|
20
20
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
21
21
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
22
22
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
package/src/hub/manager.ts
CHANGED
|
@@ -155,7 +155,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
155
155
|
return { ok: true };
|
|
156
156
|
}
|
|
157
157
|
// The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
|
|
158
|
-
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
|
|
158
|
+
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
|
|
159
159
|
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
|
|
160
160
|
const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
|
|
161
161
|
let nominalMs = 10_000;
|
package/src/hub/peer-catalog.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
|
|
3
|
+
// ponytail: older staged coordinators read these literal declarations to probe target support.
|
|
4
|
+
// A2 replaces that source probe with a published capability before deleting the compatibility exports.
|
|
5
|
+
/** Native peers the hub knows; membership checks share the registry. */
|
|
2
6
|
export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
3
7
|
/** Optional production peers are added explicitly; legacy defaults remain stable. */
|
|
4
8
|
export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
|
|
@@ -6,25 +10,18 @@ export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
|
|
|
6
10
|
export type KnownPeer = typeof SUPPORTED_PEERS[number];
|
|
7
11
|
|
|
8
12
|
export const isKnownPeer = (value: unknown): value is KnownPeer =>
|
|
9
|
-
typeof value === "string" && (
|
|
13
|
+
typeof value === "string" && !!definitionOf(value) && !definitionOf(value)!.optional;
|
|
10
14
|
|
|
11
15
|
export const isSupportedPeer = (value: unknown): value is KnownPeer =>
|
|
12
|
-
typeof value === "string" && (
|
|
16
|
+
typeof value === "string" && !!definitionOf(value);
|
|
13
17
|
|
|
14
18
|
// Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
|
|
15
|
-
const
|
|
16
|
-
claude: ["planner", "reviewer"],
|
|
17
|
-
codex: ["implementer"],
|
|
18
|
-
kimi: ["implementer", "verifier"],
|
|
19
|
-
local: ["implementer", "verifier"],
|
|
20
|
-
pi: ["implementer", "verifier"],
|
|
21
|
-
qwen: ["implementer", "verifier"],
|
|
22
|
-
jcode: ["implementer", "verifier"],
|
|
23
|
-
};
|
|
19
|
+
const ROLE_ORDER: readonly KnownPeer[] = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode"];
|
|
24
20
|
|
|
25
21
|
/** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
|
|
26
22
|
export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
|
|
27
|
-
return Object.fromEntries(
|
|
23
|
+
return Object.fromEntries(ROLE_ORDER.filter(peer => includeOptional || !definitionOf(peer)!.optional)
|
|
24
|
+
.map(peer => [peer, [...definitionOf(peer)!.roles]]));
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
/** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
|
package/src/hub/peers.ts
CHANGED
|
@@ -44,18 +44,28 @@ export abstract class BasePeer implements PeerAdapter {
|
|
|
44
44
|
onDelivery?: (receipt: DeliveryReceipt) => void;
|
|
45
45
|
private _state: PeerState = "offline";
|
|
46
46
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
private nextWatchdogMs: number | undefined;
|
|
47
48
|
|
|
48
49
|
constructor(
|
|
49
50
|
readonly id: PeerId,
|
|
50
|
-
protected
|
|
51
|
+
protected watchdogMs = DEFAULT_WATCHDOG_MS,
|
|
51
52
|
) {}
|
|
52
53
|
|
|
53
54
|
get state(): PeerState {
|
|
54
55
|
return this._state;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** Keep the active turn's inactivity bound; a changed setting belongs to the next busy turn. */
|
|
59
|
+
configureWatchdog(ms: number): void {
|
|
60
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > 2_147_483_647) throw new Error("invalid watchdog interval");
|
|
61
|
+
this.nextWatchdogMs = ms;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
protected setState(next: PeerState): void {
|
|
58
|
-
if (next === "busy")
|
|
65
|
+
if (next === "busy") {
|
|
66
|
+
if (this._state !== "busy" && this.nextWatchdogMs !== undefined) this.watchdogMs = this.nextWatchdogMs;
|
|
67
|
+
this.touch();
|
|
68
|
+
}
|
|
59
69
|
else this.clearWatchdog();
|
|
60
70
|
if (next === this._state) return;
|
|
61
71
|
this._state = next;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { isSupportedPeer } from "./peer-catalog.ts";
|
|
2
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
3
|
import { lstatSync } from "node:fs";
|
|
3
4
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
5
|
|
|
5
6
|
/** Operator-selected approval frequency; agent sandboxes remain separate. */
|
|
6
7
|
export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
|
|
7
8
|
export type PermissionMode = typeof PERMISSION_MODES[number];
|
|
8
|
-
export
|
|
9
|
-
export
|
|
9
|
+
export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
|
|
10
|
+
export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
|
|
10
11
|
export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
|
|
11
12
|
export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
|
|
12
13
|
if (value === undefined) return {};
|
|
@@ -29,9 +30,10 @@ export function permissionBoundary(peer: string): string {
|
|
|
29
30
|
}
|
|
30
31
|
/** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
|
|
31
32
|
export function permissionGrant(peer: string, mode: PermissionMode): string {
|
|
33
|
+
const grant = definitionOf(peer)?.permission.grants?.[mode];
|
|
34
|
+
if (grant !== undefined) return grant;
|
|
32
35
|
if (peer === "jcode") return "no verified hub mode mapping; native policy only";
|
|
33
36
|
if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
|
|
34
|
-
if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
|
|
35
37
|
if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
|
|
36
38
|
if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
|
|
37
39
|
if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
|
|
@@ -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/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
|
@@ -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;
|