@staix/agent-hub 0.13.0 → 0.15.0
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 +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +388 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
5
|
+
import { configTier, CONFIG_EDIT_FILES, type ConfigEditFile } from "./config-tier.ts";
|
|
6
|
+
import { sdlcConfig } from "./sdlc.ts";
|
|
7
|
+
import { VERSION } from "../version.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #689: structural config edits. This module is pure file work plus the loaders' verdict: it never publishes, never
|
|
11
|
+
* touches the board and never reads a secret. The daemon owns authority, events and reload (src/hub/daemon.ts).
|
|
12
|
+
*/
|
|
13
|
+
export interface ConfigEditSet { path: string[]; /** `null` removes the key. */ value: unknown }
|
|
14
|
+
export interface ConfigEditRequest { file: ConfigEditFile; /** The file's revision as the form read it: a sha256, or `absent`. */ revision: string; set: ConfigEditSet[] }
|
|
15
|
+
export interface ConfigDiffRow { path: string; tier: 2 | 3; before: unknown; after: unknown; applies: "restart" | "next use" }
|
|
16
|
+
export type ConfigEditPlan =
|
|
17
|
+
| { ok: true; request: ConfigEditRequest; before: string | null; next: string | null; diff: ConfigDiffRow[]; tier: 2 | 3; tier3Keys: string[]; tracked?: boolean; notes: string[] }
|
|
18
|
+
| { ok: false; error: string; field?: string };
|
|
19
|
+
/** The loaders' verdict on a candidate file text, thrown as the reason. `null` is a file that would not exist. */
|
|
20
|
+
export type CandidateCheck = (file: ConfigEditFile, text: string | null) => void;
|
|
21
|
+
|
|
22
|
+
export const MAX_EDIT_SETS = 16;
|
|
23
|
+
export const MAX_VALUE_BYTES = 16 * 1024;
|
|
24
|
+
const MAX_FILE_BYTES: Record<ConfigEditFile, number> = { "config.local.json": 1024 * 1024, "sdlc.toml": 64 * 1024 };
|
|
25
|
+
export const BACKUPS_KEPT = 5;
|
|
26
|
+
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
const sha = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
28
|
+
export const fileRevision = (text: string | null): string => text === null ? "absent" : sha(text);
|
|
29
|
+
/** A short, stable hash of a value for the audit event: the value itself never enters it. */
|
|
30
|
+
export const valueHash = (value: unknown): string | null => value === undefined ? null : sha(JSON.stringify(canonical(value))).slice(0, 16);
|
|
31
|
+
const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical) : isObject(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
32
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a) ?? null) === JSON.stringify(canonical(b) ?? null);
|
|
33
|
+
|
|
34
|
+
/** The field a loader's reason names, so a form can show it at that field. */
|
|
35
|
+
export function reasonField(message: string): string | undefined {
|
|
36
|
+
return /\b(instances\.[a-z][a-z0-9-]*(?:\.[A-Za-z_]+)?|review\.[a-z_]+|snapshots\.[a-z_]+|approvals\.[a-z_]+|context\.[a-z_]+|sdlc\.[a-z_]+)\b/.exec(message)?.[1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The file's text, or null when it is not there. A failure is named by its code: its own text holds absolute paths. */
|
|
40
|
+
export function readEditable(cwd: string, file: ConfigEditFile): string | null {
|
|
41
|
+
// Only the last component used to be checked: a committed `.agenthub` symlink would have been read and written through.
|
|
42
|
+
try { if (!lstatSync(join(cwd, ".agenthub")).isDirectory()) throw new Error(".agenthub must be a real directory, not a link"); }
|
|
43
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; if (/must be a real directory/.test((error as Error).message)) throw error; throw new Error(`.agenthub could not be read (${(error as NodeJS.ErrnoException).code ?? "unknown error"})`); }
|
|
44
|
+
const path = join(cwd, ".agenthub", file);
|
|
45
|
+
try {
|
|
46
|
+
const info = lstatSync(path);
|
|
47
|
+
if (!info.isFile()) throw Object.assign(new Error("not a regular file"), { code: "EMLINK" });
|
|
48
|
+
if (info.size > MAX_FILE_BYTES[file]) throw Object.assign(new Error("too large"), { code: "EFBIG" });
|
|
49
|
+
return readFileSync(path, "utf8");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
52
|
+
if (code === "ENOENT") return null;
|
|
53
|
+
throw new Error(`.agenthub/${file} could not be read (${code ?? "unknown error"}): it must be a regular file inside its size limit`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The revisions for a view: a file that cannot be read safely is `unavailable`, so one bad file never takes the whole Settings view down. */
|
|
57
|
+
export const viewRevisions = (cwd: string): Record<ConfigEditFile, string> => Object.fromEntries(CONFIG_EDIT_FILES.map(file => { try { return [file, fileRevision(readEditable(cwd, file))]; } catch { return [file, "unavailable"]; } })) as Record<ConfigEditFile, string>;
|
|
58
|
+
export const editRevisions = (cwd: string): Record<ConfigEditFile, string> => Object.fromEntries(CONFIG_EDIT_FILES.map(file => [file, fileRevision(readEditable(cwd, file))])) as Record<ConfigEditFile, string>;
|
|
59
|
+
|
|
60
|
+
/** Strict shape of an untrusted request: unknown fields are refused, never dropped. */
|
|
61
|
+
export function parseConfigEditRequest(raw: unknown): ConfigEditRequest | string {
|
|
62
|
+
if (!isObject(raw)) return "a config edit is an object with file, revision and set";
|
|
63
|
+
const extra = Object.keys(raw).find(key => !["file", "revision", "set"].includes(key));
|
|
64
|
+
if (extra) return `${extra}: unknown field; nothing was written`;
|
|
65
|
+
if (!CONFIG_EDIT_FILES.includes(raw.file as ConfigEditFile)) return `file: one of ${CONFIG_EDIT_FILES.join(", ")}`;
|
|
66
|
+
if (typeof raw.revision !== "string" || !/^(absent|[a-f0-9]{64})$/.test(raw.revision)) return "revision: the file revision the form read (reload and try again)";
|
|
67
|
+
if (!Array.isArray(raw.set) || !raw.set.length || raw.set.length > MAX_EDIT_SETS) return `set: 1 to ${MAX_EDIT_SETS} entries`;
|
|
68
|
+
const set: ConfigEditSet[] = [];
|
|
69
|
+
for (const [index, entry] of raw.set.entries()) {
|
|
70
|
+
if (!isObject(entry)) return `set[${index}]: an object with path and value`;
|
|
71
|
+
const unknown = Object.keys(entry).find(key => !["path", "value"].includes(key));
|
|
72
|
+
if (unknown) return `set[${index}].${unknown}: unknown field; nothing was written`;
|
|
73
|
+
if (!Array.isArray(entry.path) || !entry.path.length || entry.path.length > 6 || entry.path.some(part => typeof part !== "string")) return `set[${index}].path: 1 to 6 strings`;
|
|
74
|
+
if (!("value" in entry)) return `set[${index}].value: required (null removes the key)`;
|
|
75
|
+
let size: number;
|
|
76
|
+
try { size = Buffer.byteLength(JSON.stringify(entry.value) ?? "undefined"); } catch { return `set[${index}].value: must be plain JSON`; }
|
|
77
|
+
if (size > MAX_VALUE_BYTES) return `set[${index}].value: at most ${MAX_VALUE_BYTES} bytes`;
|
|
78
|
+
set.push({ path: [...entry.path as string[]], value: entry.value });
|
|
79
|
+
}
|
|
80
|
+
if (new Set(set.map(item => item.path.join("."))).size !== set.length) return "set: a path appears twice";
|
|
81
|
+
return { file: raw.file as ConfigEditFile, revision: raw.revision, set };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function valueAt(doc: unknown, path: readonly string[]): unknown {
|
|
85
|
+
let node = doc;
|
|
86
|
+
for (const part of path) { if (!isObject(node) || !Object.hasOwn(node, part)) return undefined; node = node[part]; }
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
/** Sets (or with null removes) one path; an emptied parent goes with it, so set then remove leaves the file as it was. */
|
|
90
|
+
function putPath(doc: Record<string, unknown>, path: readonly string[], value: unknown): void {
|
|
91
|
+
const trail: [Record<string, unknown>, string][] = [];
|
|
92
|
+
let node = doc;
|
|
93
|
+
for (const part of path.slice(0, -1)) {
|
|
94
|
+
if (!Object.hasOwn(node, part)) { if (value === null) return; node[part] = {}; }
|
|
95
|
+
const next = node[part];
|
|
96
|
+
if (!isObject(next)) throw new Error(`${path.slice(0, path.indexOf(part) + 1).join(".")} is not an object in the file; fix it in your editor first`);
|
|
97
|
+
trail.push([node, part]); node = next;
|
|
98
|
+
}
|
|
99
|
+
const leaf = path.at(-1)!;
|
|
100
|
+
if (value === null) {
|
|
101
|
+
delete node[leaf];
|
|
102
|
+
for (const [parent, part] of trail.reverse()) { if (Object.keys(parent[part] as object).length) break; delete parent[part]; }
|
|
103
|
+
} else node[leaf] = structuredClone(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** TOML top-level keys `mode` and `required_checks`, edited line by line so comments and every other table survive. */
|
|
107
|
+
function tomlValue(key: string, value: unknown): string {
|
|
108
|
+
if (key === "mode") return `mode = ${JSON.stringify(value)}`;
|
|
109
|
+
return `required_checks = [${(value as string[]).map(check => JSON.stringify(check)).join(", ")}]`;
|
|
110
|
+
}
|
|
111
|
+
const bracketDelta = (line: string): number => {
|
|
112
|
+
const bare = line.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "").replace(/#.*$/, "");
|
|
113
|
+
return [...bare].reduce((sum, ch) => sum + (ch === "[" ? 1 : ch === "]" ? -1 : 0), 0);
|
|
114
|
+
};
|
|
115
|
+
/** The top-level key spans of a TOML file (before the first table header); a multi-line array keeps its lines together. */
|
|
116
|
+
function topLevel(lines: readonly string[]): { end: number; spans: Map<string, [number, number]> } {
|
|
117
|
+
const spans = new Map<string, [number, number]>();
|
|
118
|
+
let end = lines.length;
|
|
119
|
+
for (let index = 0; index < lines.length; index++) {
|
|
120
|
+
const line = lines[index]!;
|
|
121
|
+
if (/^\s*\[/.test(line)) { end = index; break; }
|
|
122
|
+
const key = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line)?.[1];
|
|
123
|
+
if (!key) continue;
|
|
124
|
+
let last = index, depth = bracketDelta(line.slice(line.indexOf("=") + 1));
|
|
125
|
+
while (depth > 0 && last + 1 < lines.length) depth += bracketDelta(lines[++last]!);
|
|
126
|
+
spans.set(key, [index, last]);
|
|
127
|
+
index = last;
|
|
128
|
+
}
|
|
129
|
+
return { end, spans };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Edits `mode` and `required_checks` line by line. Every line the edit does not touch keeps its own ending, so a one-key change never
|
|
133
|
+
* rewrites a CRLF checkout; a line the edit writes or adds takes the ending of the first line break the file has (LF for a new file).
|
|
134
|
+
*/
|
|
135
|
+
function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
|
|
136
|
+
const raw = text === null ? [] : text.split("\n");
|
|
137
|
+
const trailing = raw.length > 0 && raw.at(-1) === "";
|
|
138
|
+
if (trailing) raw.pop();
|
|
139
|
+
// Parallel arrays: the line's text and the ending it had. A line that was the file's last may have had none.
|
|
140
|
+
const lines = raw.map(line => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
141
|
+
const endings = raw.map((line, index) => line.endsWith("\r") ? "\r\n" : index === raw.length - 1 && !trailing ? "" : "\n");
|
|
142
|
+
const own = text !== null && /\r?\n/.exec(text)?.[0] === "\r\n" ? "\r\n" : "\n";
|
|
143
|
+
for (const { path, value } of set) {
|
|
144
|
+
const key = path[0]!, { end, spans } = topLevel(lines), found = spans.get(key);
|
|
145
|
+
if (found) {
|
|
146
|
+
const fresh = value === null ? [] : [tomlValue(key, value)];
|
|
147
|
+
const last = endings[found[1]]!;
|
|
148
|
+
lines.splice(found[0], found[1] - found[0] + 1, ...fresh);
|
|
149
|
+
endings.splice(found[0], found[1] - found[0] + 1, ...fresh.map(() => last === "" ? own : last));
|
|
150
|
+
} else if (value !== null) {
|
|
151
|
+
// A new key goes after the last top-level line, before the first table.
|
|
152
|
+
let at = end;
|
|
153
|
+
while (at > 0 && !lines[at - 1]!.trim()) at--;
|
|
154
|
+
lines.splice(at, 0, tomlValue(key, value)); endings.splice(at, 0, own);
|
|
155
|
+
if (at > 0 && endings[at - 1] === "") endings[at - 1] = own;
|
|
156
|
+
if (at + 1 < lines.length && lines[at + 1]!.trim() !== "" && /^\s*\[/.test(lines[at + 1]!)) { lines.splice(at + 1, 0, ""); endings.splice(at + 1, 0, own); }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!lines.length) return null;
|
|
160
|
+
return lines.map((line, index) => line + (endings[index] === "" && index === lines.length - 1 ? own : endings[index])).join("");
|
|
161
|
+
}
|
|
162
|
+
const parseToml = (text: string | null): Record<string, unknown> => text === null ? {} : Bun.TOML.parse(text) as Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* What the request would do, judged by the loaders before anything is written. The result carries the diff and the
|
|
166
|
+
* candidate text; nothing has been written. A refusal names the key (`field`) when the loader's reason does.
|
|
167
|
+
*/
|
|
168
|
+
export function planConfigEdit(cwd: string, rawRequest: unknown, check: CandidateCheck): ConfigEditPlan {
|
|
169
|
+
const request = parseConfigEditRequest(rawRequest);
|
|
170
|
+
if (typeof request === "string") return { ok: false, error: request };
|
|
171
|
+
try {
|
|
172
|
+
const before = readEditable(cwd, request.file);
|
|
173
|
+
if (fileRevision(before) !== request.revision) return { ok: false, error: `.agenthub/${request.file} changed on disk since it was read: reload it and review the change again; nothing was written` };
|
|
174
|
+
const diff: ConfigDiffRow[] = [], tier3Keys: string[] = [];
|
|
175
|
+
let next: string | null;
|
|
176
|
+
if (request.file === "sdlc.toml") {
|
|
177
|
+
for (const { path, value } of request.set) {
|
|
178
|
+
const tier = configTier("sdlc.toml", path, value);
|
|
179
|
+
if ("refused" in tier) return { ok: false, error: tier.refused, field: `sdlc.${path.join(".")}` };
|
|
180
|
+
if (path[0] === "mode" && value !== null && !["off", "advise", "enforce"].includes(String(value))) return { ok: false, error: "sdlc.mode: expected off, advise or enforce; nothing was written", field: "sdlc.mode" };
|
|
181
|
+
if (path[0] === "required_checks" && value !== null && !Array.isArray(value)) return { ok: false, error: "sdlc.required_checks: expected a list of check names; nothing was written", field: "sdlc.required_checks" };
|
|
182
|
+
}
|
|
183
|
+
const old = parseToml(before);
|
|
184
|
+
next = editSdlcText(before, request.set);
|
|
185
|
+
const after = parseToml(next);
|
|
186
|
+
const expected = structuredClone(old);
|
|
187
|
+
for (const { path, value } of request.set) putPath(expected, path, value);
|
|
188
|
+
// A layout this edit cannot keep byte-faithful is refused rather than guessed: only the named keys may differ.
|
|
189
|
+
if (!same(after, expected)) return { ok: false, error: "sdlc.toml has a layout the editor cannot change safely (a key repeated, or one split across tables); edit .agenthub/sdlc.toml in your editor; nothing was written" };
|
|
190
|
+
sdlcConfig(after, VERSION); // the loader's own verdict, with its field-level reason
|
|
191
|
+
for (const { path, value } of request.set) diff.push({ path: `sdlc.${path.join(".")}`, tier: 2, before: old[path[0]!], after: value === null ? undefined : value, applies: "restart" });
|
|
192
|
+
check("sdlc.toml", next);
|
|
193
|
+
} else {
|
|
194
|
+
let doc: Record<string, unknown>;
|
|
195
|
+
try { doc = before === null ? {} : JSON.parse(before); } catch { return { ok: false, error: ".agenthub/config.local.json is not valid JSON; fix it in your editor first; nothing was written" }; }
|
|
196
|
+
if (!isObject(doc)) return { ok: false, error: ".agenthub/config.local.json must hold a JSON object; nothing was written" };
|
|
197
|
+
const old = structuredClone(doc);
|
|
198
|
+
for (const { path, value } of request.set) {
|
|
199
|
+
const held = valueAt(old, path);
|
|
200
|
+
const tier = configTier("config.local.json", path, value, held);
|
|
201
|
+
if ("refused" in tier) return { ok: false, error: tier.refused, field: path.join(".") };
|
|
202
|
+
if (tier.tier === 3) tier3Keys.push(tier.key);
|
|
203
|
+
// A whole instance replaced by a tier 2 object must not drop a tier 3 field it held: that would silently erase a home or account.
|
|
204
|
+
if (path[0] === "instances" && path.length === 2 && isObject(held) && isObject(value)) {
|
|
205
|
+
const lost = Object.keys(held).find(field => !(field in value) && ["jcode_home", "provider", "model", "provider_profile"].includes(field));
|
|
206
|
+
if (lost) return { ok: false, error: `instances.${path[1]}.${lost}: this replacement would drop a native home or account setting; set only the fields you change (instances.${path[1]}.<field>); nothing was written`, field: `instances.${path[1]}.${lost}` };
|
|
207
|
+
}
|
|
208
|
+
putPath(doc, path, value);
|
|
209
|
+
diff.push({ path: path.join("."), tier: tier.tier, before: held, after: value === null ? undefined : value, applies: path[0] === "instances" ? "restart" : "next use" });
|
|
210
|
+
}
|
|
211
|
+
next = Object.keys(doc).length || before !== null ? `${JSON.stringify(doc, null, 2)}\n` : null;
|
|
212
|
+
if (Buffer.byteLength(next ?? "") > MAX_FILE_BYTES["config.local.json"]) return { ok: false, error: ".agenthub/config.local.json would exceed its size limit; nothing was written" };
|
|
213
|
+
check("config.local.json", next);
|
|
214
|
+
}
|
|
215
|
+
const changed = diff.filter(row => !same(row.before, row.after));
|
|
216
|
+
if (!changed.length) return { ok: false, error: "nothing to change: the file already holds this; nothing was written" };
|
|
217
|
+
const tracked = request.file === "config.local.json" ? undefined : configTracked(cwd, "sdlc.toml");
|
|
218
|
+
const notes = request.file === "sdlc.toml" && tracked ? [".agenthub/sdlc.toml is tracked by git: the change is in your working tree, commit it to share it"] : [];
|
|
219
|
+
return { ok: true, request, before, next, diff: changed, tier: tier3Keys.length ? 3 : 2, tier3Keys: [...new Set(tier3Keys)], ...(tracked === undefined ? {} : { tracked }), notes };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const message = (error as Error).message;
|
|
222
|
+
return { ok: false, error: `${message}; nothing was written`, ...(reasonField(message) ? { field: reasonField(message) } : {}) };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const backupDir = (stateDir: string): string => join(stateDir, "config-backups");
|
|
227
|
+
export interface ConfigBackup { name: string; file: ConfigEditFile; at: number; bytes: number }
|
|
228
|
+
/** The newest backups first, at most `BACKUPS_KEPT` per file. */
|
|
229
|
+
export function configBackups(stateDir: string): ConfigBackup[] {
|
|
230
|
+
let names: string[];
|
|
231
|
+
try { names = readdirSync(backupDir(stateDir)); } catch { return []; }
|
|
232
|
+
return names.flatMap(name => {
|
|
233
|
+
const match = /^(config\.local\.json|sdlc\.toml)\.(\d{13})\.[a-f0-9]{8}\.bak$/.exec(name);
|
|
234
|
+
if (!match) return [];
|
|
235
|
+
try { return [{ name, file: match[1] as ConfigEditFile, at: Number(match[2]), bytes: statSync(join(backupDir(stateDir), name)).size }]; } catch { return []; }
|
|
236
|
+
}).sort((a, b) => b.at - a.at || (a.name < b.name ? 1 : -1));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Temp file and rename in the same directory: a reader sees the old file or the new one, never half of one. */
|
|
240
|
+
function atomicWrite(path: string, text: string, mode: number): void {
|
|
241
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
242
|
+
try { writeFileSync(tmp, text, { mode, flag: "wx" }); chmodSync(tmp, mode); renameSync(tmp, path); }
|
|
243
|
+
catch (error) { try { unlinkSync(tmp); } catch { /* nothing was written */ } throw error; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A git-tracked machine-local file is never written: its content would travel with the repository. */
|
|
247
|
+
function refuseTrackedLocal(cwd: string): void {
|
|
248
|
+
const tracked = configTracked(cwd, "config.local.json");
|
|
249
|
+
if (tracked) throw new Error(".agenthub/config.local.json is committed to git; structural edits write only machine-local files. Remove it from the repository (git rm --cached) and ignore it");
|
|
250
|
+
if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error("git could not confirm that .agenthub/config.local.json is untracked; nothing was written");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Writes a plan that `planConfigEdit` accepted: the revision is checked once more against the disk, the previous file is kept
|
|
255
|
+
* as a bounded backup, then the file is replaced atomically. Returns what the audit event records (hashes, never values).
|
|
256
|
+
*/
|
|
257
|
+
export function writeConfigEdit(cwd: string, stateDir: string, plan: Extract<ConfigEditPlan, { ok: true }>, now: number = Date.now()): { backup?: string; fileBefore: string; fileAfter: string; paths: { path: string; before: string | null; after: string | null }[] } {
|
|
258
|
+
const { file } = plan.request, path = join(cwd, ".agenthub", file);
|
|
259
|
+
try {
|
|
260
|
+
if (file === "config.local.json") refuseTrackedLocal(cwd);
|
|
261
|
+
if (fileRevision(readEditable(cwd, file)) !== plan.request.revision) throw new Error(`.agenthub/${file} changed on disk since it was read: reload it and review the change again`);
|
|
262
|
+
let backup: string | undefined;
|
|
263
|
+
if (plan.before !== null) {
|
|
264
|
+
mkdirSync(backupDir(stateDir), { recursive: true, mode: 0o700 });
|
|
265
|
+
backup = `${file}.${String(now).padStart(13, "0")}.${sha(plan.before).slice(0, 8)}.bak`;
|
|
266
|
+
writeFileSync(join(backupDir(stateDir), backup), plan.before, { mode: 0o600 });
|
|
267
|
+
for (const old of configBackups(stateDir).filter(item => item.file === file).slice(BACKUPS_KEPT)) rmSync(join(backupDir(stateDir), old.name), { force: true });
|
|
268
|
+
}
|
|
269
|
+
mkdirSync(join(cwd, ".agenthub"), { recursive: true });
|
|
270
|
+
let mode = 0o600;
|
|
271
|
+
try { mode = statSync(path).mode & 0o777; } catch { /* a new file is the owner's alone */ }
|
|
272
|
+
if (plan.next === null) rmSync(path, { force: true }); else atomicWrite(path, plan.next, mode);
|
|
273
|
+
return { ...(backup ? { backup } : {}), fileBefore: fileRevision(plan.before), fileAfter: fileRevision(plan.next),
|
|
274
|
+
// A tier 3 value (a model name, a provider, a home) is low-entropy and dictionary-recoverable from an unsalted hash, so it gets none:
|
|
275
|
+
// the event shows that the key changed and the file revisions bind the change. A tier 2 value keeps its short hash.
|
|
276
|
+
paths: plan.diff.map(row => row.tier === 3 ? { path: row.path, before: row.before === undefined ? null : "(set)", after: row.after === undefined ? null : "(set)" } : { path: row.path, before: valueHash(row.before), after: valueHash(row.after) }) };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
279
|
+
if (code && !/\.agenthub|git/.test((error as Error).message)) throw new Error(`.agenthub/${file} could not be written (${code}); nothing was changed`);
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NEVER_EDITABLE, SETTINGS } from "./settings.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #689: which structural keys a person may edit, and from where.
|
|
5
|
+
* Tier 1 is the settings registry (already editable, unchanged). Tier 2 is structural config, editable from the console and
|
|
6
|
+
* from a dashboard settings session. Tier 3 chooses what the hub executes or where secrets go: console only, typed key.
|
|
7
|
+
* One rule serves the editor, the dashboard's server-side refusal and reload authority, so none of them can drift.
|
|
8
|
+
*/
|
|
9
|
+
export const CONFIG_EDIT_FILES = ["config.local.json", "sdlc.toml"] as const;
|
|
10
|
+
export type ConfigEditFile = typeof CONFIG_EDIT_FILES[number];
|
|
11
|
+
export type ConfigTier = { tier: 2 } | { tier: 3; key: string } | { refused: string };
|
|
12
|
+
|
|
13
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
14
|
+
const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
|
|
15
|
+
/** Instance fields that choose no executable and no home. */
|
|
16
|
+
export const TIER2_INSTANCE_FIELDS = ["kind", "roles", "start_mode", "review_mode", "readOnly", "worktree", "permission_mode"] as const;
|
|
17
|
+
/** Instance fields that choose a native home, account or model: tier 3 (#685). */
|
|
18
|
+
export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as const;
|
|
19
|
+
/** Every NEVER_EDITABLE key except `instances`, which splits by field. */
|
|
20
|
+
export const TIER3_PREFIXES: readonly string[] = NEVER_EDITABLE.filter(key => key !== "instances");
|
|
21
|
+
const TIER2_BLOCKS: Readonly<Record<string, readonly string[]>> = {
|
|
22
|
+
review: ["adaptive", "min_reviews", "pool", "rerotate"],
|
|
23
|
+
snapshots: ["keep"],
|
|
24
|
+
approvals: ["timeout_s"],
|
|
25
|
+
context: ["gate"],
|
|
26
|
+
};
|
|
27
|
+
const SDLC_KEYS = ["mode", "required_checks"] as const;
|
|
28
|
+
/** Registry-owned keys keep their own write path: a roster change moves tasks and stops peers, and a WIP limit applies live. */
|
|
29
|
+
const REGISTRY_PREFIXES = ["roster", "tasks.wip_limits", "permission_modes", "catalog_roles", "peers"] as const;
|
|
30
|
+
const registryKey = (key: string): boolean => SETTINGS.some(def => def.key === key || def.path.join(".") === key) || REGISTRY_PREFIXES.some(prefix => key === prefix || key.startsWith(`${prefix}.`));
|
|
31
|
+
|
|
32
|
+
const refuse = (reason: string): { refused: string } => ({ refused: `${reason}; nothing was written` });
|
|
33
|
+
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The tier of one edit. `value` is only read for a whole `instances.<id>` object, whose fields are classified one by one
|
|
37
|
+
* (the highest tier wins, an unknown field is refused by name, never dropped).
|
|
38
|
+
*/
|
|
39
|
+
export function configTier(file: ConfigEditFile, path: readonly string[], value?: unknown, held?: unknown): ConfigTier {
|
|
40
|
+
if (!path.length || path.some(part => typeof part !== "string" || !part || part.length > 64 || UNSAFE.has(part) || /[\s\x00-\x1f]/.test(part))) return refuse("invalid configuration path");
|
|
41
|
+
const key = path.join(".");
|
|
42
|
+
if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
|
|
43
|
+
if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
|
|
44
|
+
if (path[0] === "instances") {
|
|
45
|
+
if (path.length === 1) return refuse("instances: name one instance (instances.<id>)");
|
|
46
|
+
if (!PEER.test(path[1]!)) return refuse(`instances.${path[1]}: an instance id is lower-case letters, digits and hyphens`);
|
|
47
|
+
if (path.length === 2) {
|
|
48
|
+
// A whole instance that already holds a native home, account or model is tier 3 to remove or replace, whatever the new value is:
|
|
49
|
+
// its values are what a browser must never see or delete (#689 review).
|
|
50
|
+
if (isObject(held) && TIER3_INSTANCE_FIELDS.some(field => Object.hasOwn(held, field))) return { tier: 3, key: `instances.${path[1]}` };
|
|
51
|
+
if (value === undefined || value === null) return { tier: 2 }; // removal of a whole declaration; the loaders judge what depends on it
|
|
52
|
+
if (!isObject(value)) return refuse(`instances.${path[1]}: must be an object of instance fields`);
|
|
53
|
+
let tier3: string | undefined;
|
|
54
|
+
for (const field of Object.keys(value)) {
|
|
55
|
+
if ((TIER3_INSTANCE_FIELDS as readonly string[]).includes(field)) tier3 = `instances.${path[1]}`;
|
|
56
|
+
else if (!(TIER2_INSTANCE_FIELDS as readonly string[]).includes(field)) return refuse(`instances.${path[1]}.${field}: unknown instance field`);
|
|
57
|
+
}
|
|
58
|
+
return tier3 ? { tier: 3, key: tier3 } : { tier: 2 };
|
|
59
|
+
}
|
|
60
|
+
if (path.length === 3) {
|
|
61
|
+
if ((TIER3_INSTANCE_FIELDS as readonly string[]).includes(path[2]!)) return { tier: 3, key };
|
|
62
|
+
if ((TIER2_INSTANCE_FIELDS as readonly string[]).includes(path[2]!)) return { tier: 2 };
|
|
63
|
+
}
|
|
64
|
+
return refuse(`${key}: unknown instance field`);
|
|
65
|
+
}
|
|
66
|
+
const never = TIER3_PREFIXES.find(prefix => key === prefix || key.startsWith(`${prefix}.`));
|
|
67
|
+
if (never) return { tier: 3, key };
|
|
68
|
+
const fields = TIER2_BLOCKS[path[0]!];
|
|
69
|
+
if (fields && path.length === 2 && fields.includes(path[1]!)) return { tier: 2 };
|
|
70
|
+
return refuse(`${key} is not a structural setting`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether reload authority may take this key as a confirmed structural edit: tier 2 only, never an executable or secret key. */
|
|
74
|
+
export function structuralLeaf(key: string): boolean {
|
|
75
|
+
const path = key.split(".");
|
|
76
|
+
const tier = configTier("config.local.json", path);
|
|
77
|
+
return "tier" in tier && tier.tier === 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A value as a reply may show it: a tier 3 key is only ever `(set)`, and inside a whole instance only its tier 3 fields are. */
|
|
81
|
+
export function maskTier3(path: readonly string[], value: unknown): unknown {
|
|
82
|
+
if (value === undefined) return undefined;
|
|
83
|
+
const key = path.join(".");
|
|
84
|
+
if (path[0] === "instances" && path.length === 2 && isObject(value)) return Object.fromEntries(Object.entries(value).map(([field, item]) => [field, (TIER3_INSTANCE_FIELDS as readonly string[]).includes(field) ? "(set)" : item]));
|
|
85
|
+
const tier = configTier("config.local.json", path, undefined);
|
|
86
|
+
if ("tier" in tier && tier.tier === 3) return "(set)";
|
|
87
|
+
if (TIER3_PREFIXES.some(prefix => key === prefix || key.startsWith(`${prefix}.`))) return "(set)";
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
@@ -9,10 +9,29 @@ export function stateDirFor(cwd: string): string {
|
|
|
9
9
|
|
|
10
10
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
11
11
|
export const PROTOCOL = 16;
|
|
12
|
-
export const DAEMON_FEATURES = ["external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
12
|
+
export const DAEMON_FEATURES = ["external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "role_stages", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
13
13
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
14
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
15
15
|
|
|
16
|
+
/** Only a launch retries transient transport failures. Refusals and identity mismatches
|
|
17
|
+
* remain terminal; every attempt rereads the descriptor and applies the instance pin. */
|
|
18
|
+
export async function retryLaunchConnect<T>(attempt: (timeoutMs: number) => Promise<T>, onRetry: () => void,
|
|
19
|
+
ports: { now(): number; sleep(ms: number): Promise<unknown> } = { now: Date.now, sleep: ms => Bun.sleep(ms) }): Promise<T> {
|
|
20
|
+
const until = ports.now() + 15_000;
|
|
21
|
+
let backoff = 250, announced = false;
|
|
22
|
+
for (;;) {
|
|
23
|
+
try { return await attempt(Math.max(1, Math.min(3000, until - ports.now()))); }
|
|
24
|
+
catch (error) {
|
|
25
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
26
|
+
if (!["AHUB_CONNECT_TIMEOUT", 1006].includes(code as string | number) || ports.now() >= until) throw error;
|
|
27
|
+
if (!announced) { announced = true; onRetry(); }
|
|
28
|
+
await ports.sleep(Math.min(backoff, until - ports.now()));
|
|
29
|
+
if (ports.now() >= until) throw error;
|
|
30
|
+
backoff = Math.min(1000, backoff * 2);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
export interface Hello {
|
|
17
36
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
18
37
|
role: "peer" | "console" | "tools";
|
|
@@ -21,6 +40,8 @@ export interface Hello {
|
|
|
21
40
|
channel?: boolean;
|
|
22
41
|
/** Managed Claude launch identity; absent for plain or pre-daemon sessions. */
|
|
23
42
|
launchId?: string;
|
|
43
|
+
/** Optional canonical native execution cwd; ownership requires independent host proof. */
|
|
44
|
+
cwd?: string;
|
|
24
45
|
/** Reclaim after replacement, never an intentional initial takeover. */
|
|
25
46
|
standby?: boolean;
|
|
26
47
|
projectId?: string;
|
|
@@ -110,9 +131,9 @@ export class ControlClient {
|
|
|
110
131
|
return new Promise((resolve, reject) => {
|
|
111
132
|
const ws = new WebSocket(control.url);
|
|
112
133
|
const client = new ControlClient(ws);
|
|
113
|
-
const timer = setTimeout(() => refuse(new Error(`hub connection timed out at ${control.url}`)), timeoutMs);
|
|
134
|
+
const timer = setTimeout(() => refuse(Object.assign(new Error(`hub connection timed out at ${control.url}`), { code: "AHUB_CONNECT_TIMEOUT" })), timeoutMs);
|
|
114
135
|
const refuse = (error: Error) => { clearTimeout(timer); reject(error); ws.close(); };
|
|
115
|
-
ws.onerror = () => refuse(new Error(`cannot reach hub at ${control.url}`));
|
|
136
|
+
ws.onerror = () => refuse(Object.assign(new Error(`cannot reach hub at ${control.url}`), { code: "AHUB_CONNECT_UNREACHABLE" }));
|
|
116
137
|
ws.onclose = (ev) => {
|
|
117
138
|
clearTimeout(timer);
|
|
118
139
|
// A refused hello carries its reason (bad token, wire version, peer id); say that, not a guess.
|