@staix/agent-hub 0.14.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -6
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +17 -3
  5. package/docs/agent-notes/budget.md +4 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +14 -9
  8. package/docs/agent-notes/tasks.md +9 -4
  9. package/docs/agent-notes/tests.md +3 -3
  10. package/docs/dogfood.md +9 -4
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +90 -18
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +17 -0
  16. package/docs/security.md +37 -10
  17. package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
  18. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  19. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  20. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  21. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  22. package/docs/verified.json +29 -17
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +992 -312
  26. package/src/adapters/acp.ts +43 -7
  27. package/src/adapters/claude-channel.ts +26 -4
  28. package/src/adapters/claude-implementer.ts +243 -0
  29. package/src/adapters/claude-print-child.ts +90 -0
  30. package/src/adapters/claude-task-sessions.ts +80 -0
  31. package/src/adapters/compat.ts +4 -0
  32. package/src/adapters/conformance/enrollment.ts +2 -1
  33. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  34. package/src/adapters/definitions/builtin-metadata.ts +14 -2
  35. package/src/adapters/definitions/native-channel-state.ts +1 -1
  36. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  37. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  38. package/src/adapters/definitions/native-factories.ts +8 -0
  39. package/src/adapters/kimi-wire-usage.ts +303 -0
  40. package/src/adapters/registry-types.ts +7 -3
  41. package/src/adapters/registry.ts +14 -2
  42. package/src/adapters/reviewer-print.ts +30 -53
  43. package/src/cli/console-config.ts +123 -0
  44. package/src/cli/console-state.ts +28 -1
  45. package/src/cli/console.ts +25 -1
  46. package/src/cli/facts-hook.ts +9 -5
  47. package/src/cli/help.ts +4 -3
  48. package/src/cli/launch.ts +10 -4
  49. package/src/cli/main.ts +47 -9
  50. package/src/cli/output.ts +28 -0
  51. package/src/cli/reset.ts +1 -1
  52. package/src/cli/settings-edit.ts +89 -0
  53. package/src/cli/terminal-recovery.ts +1 -6
  54. package/src/hub/action-enforce.ts +82 -0
  55. package/src/hub/action-policy.ts +22 -10
  56. package/src/hub/board.ts +26 -2
  57. package/src/hub/budget.ts +5 -0
  58. package/src/hub/bus.ts +5 -5
  59. package/src/hub/claude-launch-proof.ts +33 -0
  60. package/src/hub/config-apply.ts +5 -0
  61. package/src/hub/config-edit-host.ts +173 -0
  62. package/src/hub/config-edit.ts +282 -0
  63. package/src/hub/config-tier.ts +89 -0
  64. package/src/hub/control-client.ts +24 -3
  65. package/src/hub/daemon.ts +368 -59
  66. package/src/hub/deliverable-files.ts +131 -0
  67. package/src/hub/deliverables.ts +125 -0
  68. package/src/hub/delivery-journal.ts +18 -2
  69. package/src/hub/events.ts +3 -1
  70. package/src/hub/hub-tools.ts +19 -3
  71. package/src/hub/instances.ts +7 -4
  72. package/src/hub/kimi-usage.ts +144 -0
  73. package/src/hub/manager.ts +1 -1
  74. package/src/hub/peers.ts +7 -0
  75. package/src/hub/permission-payload.ts +15 -0
  76. package/src/hub/routing.ts +118 -3
  77. package/src/hub/settings.ts +1 -1
  78. package/src/hub/tasks.ts +202 -15
  79. package/src/hub/token-window.ts +33 -0
  80. package/src/hub/usage-rollup.ts +16 -6
  81. package/src/hub/workflow.ts +16 -0
  82. package/src/ui/index.html +115 -1
@@ -5,6 +5,7 @@ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../ad
5
5
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
6
6
  import type { HubConfig } from "./daemon.ts";
7
7
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
8
+ import { structuralLeaf } from "./config-tier.ts";
8
9
  import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
9
10
  import { CLASSES } from "./board.ts";
10
11
  import { permissionDefaults } from "./permission-mode.ts";
@@ -44,6 +45,8 @@ export interface ConfigAuthorizationInput {
44
45
  acceptedFallback?: boolean;
45
46
  /** The prospective settled planner/conductor holder sets change, even if the raw role list shrinks. */
46
47
  settledHolderChange?: boolean;
48
+ /** #689: the daemon sets this only for a structural edit it validated and wrote itself; `structuralLeaf` still decides the key. */
49
+ structural?: boolean;
47
50
  guard?: ReloadGuard;
48
51
  }
49
52
  export interface ConfigAuthorizationDecision { key: string; allowed: boolean; guarded: boolean; reason: string }
@@ -90,6 +93,8 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
90
93
  // Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
91
94
  if (input.tracked !== false) return result(false, true, input.tracked ? "guarded source is tracked; retain the running value" : "guarded source trust is unknown; retain the running value");
92
95
  if (input.authority === "ordinary") return result(false, true, "guarded change needs a selected setting or a confirmed terminal reload");
96
+ // #689: a structural key (never an executable or secret one) written by the daemon's own validated edit, from a settings session.
97
+ if (input.authority === "settings" && !guard && input.structural === true && input.confirmed === true && input.source === "config" && input.sourceFile === "config.local.json" && structuralLeaf(key)) return result(true, true, "confirmed structural edit");
93
98
  if (input.authority === "settings" && !guard) return result(false, true, "nonregistered policy requires a confirmed terminal reload");
94
99
  if (input.confirmed !== true) return result(false, true, "guarded change needs explicit confirmation for this key");
95
100
  return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
@@ -0,0 +1,173 @@
1
+ import { configBackups, parseConfigEditRequest, viewRevisions, planConfigEdit, writeConfigEdit, type CandidateCheck, type ConfigEditPlan } from "./config-edit.ts";
2
+ import { configTier, maskTier3, TIER2_INSTANCE_FIELDS, TIER3_INSTANCE_FIELDS, TIER3_PREFIXES, type ConfigEditFile } from "./config-tier.ts";
3
+ import type { HubEvent } from "./events.ts";
4
+
5
+ /**
6
+ * #689: the daemon's side of structural config edits. Everything the daemon decides is here, with its ports injected, so the
7
+ * daemon itself carries only a few lines of wiring: who may edit (authority), the typed confirmation for tier 3, the hold during
8
+ * recovery or shutdown, the audit event and the scoped reload that applies or marks each key.
9
+ */
10
+ export type EditAuthority = "ordinary" | "settings" | "terminal";
11
+ export interface ConfigEditHost {
12
+ cwd: string;
13
+ stateDir: string;
14
+ /** The loaders' verdict on a candidate file; throws the loader's reason. */
15
+ check: CandidateCheck;
16
+ /** A reason edits are held now (recovery or shutdown), or undefined. */
17
+ held: () => string | undefined;
18
+ /** Publishes one changed key through the daemon's only reload path, scoped to it. */
19
+ reload: (authority: EditAuthority, key: string) => Promise<Record<string, unknown>>;
20
+ event: (event: Extract<HubEvent, { type: "config_edit" }>) => void;
21
+ notify: (text: string) => void;
22
+ /** Called after a write so status.json and the console snapshot show what is now pending. */
23
+ changed: () => void;
24
+ /** What waits for a restart now, from the reload's own bookkeeping (instance declarations are restart-bound). */
25
+ pending: () => { key: string; reason: string }[];
26
+ /** The running hub's value at a config path, for the form. */
27
+ effective: (path: readonly string[]) => unknown;
28
+ /** The lifecycle the running hub holds, to say what a changed sdlc.toml waits for. */
29
+ runningSdlc: () => { mode: string; requiredChecks: readonly string[] };
30
+ /** The lifecycle the file would give at the next start, or the loader's reason. */
31
+ fileSdlc: () => { mode: string; requiredChecks: readonly string[] } | string;
32
+ /** The declared instances of the running hub: id to its tier 2 fields. */
33
+ declared: () => Record<string, Record<string, unknown>>;
34
+ /** config.local.json as the file holds it now, parsed once per call (`{}` when it is missing or unreadable). Never sent anywhere: it is read for what is set, never shown. */
35
+ localConfig: () => Record<string, unknown>;
36
+ }
37
+ export interface ConfigEditView {
38
+ revisions: Record<ConfigEditFile, string>;
39
+ instances: { id: string; fields: Record<string, unknown>; tier3: string[] }[];
40
+ /** The instance ids config.local.json holds now, including ones the running hub has not started with yet: the forms change those leaf by leaf. */
41
+ known: string[];
42
+ blocks: { path: string; value: unknown }[];
43
+ sdlc: { mode: string; requiredChecks: readonly string[]; pending: boolean; problem?: string };
44
+ /** Tier 3 keys with the console command that changes them; the dashboard shows exactly this, read-only. */
45
+ tier3: { key: string; set: boolean; command: string }[];
46
+ backups: { name: string; file: ConfigEditFile; at: number; bytes: number }[];
47
+ fields: { instance: readonly string[]; tier3Instance: readonly string[] };
48
+ }
49
+ const REASON_BYTES = 400;
50
+ const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
51
+
52
+ export const CONFIG_EDIT_OPS = ["config_edit_get", "config_edit_preview", "config_edit_apply"] as const;
53
+
54
+ export function createConfigEditor(host: ConfigEditHost) {
55
+ const sdlcPending = (): boolean => {
56
+ const file = host.fileSdlc(), running = host.runningSdlc();
57
+ return typeof file !== "string" && (file.mode !== running.mode || JSON.stringify(file.requiredChecks) !== JSON.stringify(running.requiredChecks));
58
+ };
59
+ /** What a changed sdlc.toml waits for: shown with the other pending restarts. */
60
+ const sdlcRestart = (): { key: string; reason: string }[] => {
61
+ const file = host.fileSdlc(), running = host.runningSdlc();
62
+ if (typeof file === "string") return [];
63
+ return [...(file.mode !== running.mode ? [{ key: "sdlc.mode", reason: "the lifecycle is read once at hub start" }] : []),
64
+ ...(JSON.stringify(file.requiredChecks) !== JSON.stringify(running.requiredChecks) ? [{ key: "sdlc.required_checks", reason: "the lifecycle is read once at hub start" }] : [])];
65
+ };
66
+ const pendingRestart = (): { key: string; reason: string }[] => sdlcRestart();
67
+ const allPending = (): string[] => [...host.pending().map(row => row.key), ...sdlcRestart().map(row => row.key)];
68
+ function view(): ConfigEditView {
69
+ const file = host.fileSdlc(), running = host.runningSdlc();
70
+ let local: Record<string, unknown> = {};
71
+ try { local = host.localConfig(); } catch { /* the revisions say the file is unavailable */ }
72
+ const fileInstances = local.instances && typeof local.instances === "object" && !Array.isArray(local.instances) ? Object.keys(local.instances) : [];
73
+ const isSet = (path: readonly string[]): boolean => { let node: unknown = local; for (const part of path) { if (!node || typeof node !== "object" || !Object.hasOwn(node, part)) return false; node = (node as Record<string, unknown>)[part]; } return node !== undefined; };
74
+ const declared = host.declared();
75
+ return {
76
+ revisions: viewRevisions(host.cwd),
77
+ known: fileInstances,
78
+ instances: Object.entries(declared).map(([id, fields]) => ({ id, fields: Object.fromEntries(TIER2_INSTANCE_FIELDS.filter(field => fields[field] !== undefined).map(field => [field, fields[field]])),
79
+ tier3: TIER3_INSTANCE_FIELDS.filter(field => fields[field] !== undefined) as string[] })),
80
+ blocks: BLOCKS.map(path => ({ path: path.join("."), value: host.effective(path) ?? null })),
81
+ sdlc: { mode: running.mode, requiredChecks: running.requiredChecks, pending: sdlcPending(), ...(typeof file === "string" ? { problem: file } : {}) },
82
+ tier3: [...TIER3_PREFIXES.map(key => ({ key, set: isSet(key.split(".")), command: `ahub settings edit config.local.json set ${key} <json>` })),
83
+ ...Object.entries(declared).flatMap(([id, fields]) => TIER3_INSTANCE_FIELDS.filter(field => fields[field] !== undefined).map(field => ({ key: `instances.${id}.${field}`, set: true, command: `ahub settings edit config.local.json set instances.${id}.${field} <json>` })))],
84
+ backups: configBackups(host.stateDir).slice(0, 10),
85
+ fields: { instance: TIER2_INSTANCE_FIELDS, tier3Instance: TIER3_INSTANCE_FIELDS },
86
+ };
87
+ }
88
+ /** A refusal before any loader runs: an unauthorized or unclassifiable request learns nothing about the project. */
89
+ function authorize(raw: unknown, authority: EditAuthority): { ok: true; tier3: string[] } | { ok: false; error: string } {
90
+ if (authority === "ordinary") return { ok: false, error: "structural config edits need a settings session: open one with ahub ui --settings, or use the console" };
91
+ const request = parseConfigEditRequest(raw);
92
+ if (typeof request === "string") return { ok: false, error: request };
93
+ const tier3: string[] = [];
94
+ for (const { path, value } of request.set) {
95
+ const tier = configTier(request.file, path, value);
96
+ if ("refused" in tier) return { ok: false, error: tier.refused };
97
+ if (tier.tier === 3) tier3.push(tier.key);
98
+ }
99
+ if (tier3.length && authority !== "terminal") return { ok: false, error: `${tier3[0]} chooses what the hub runs or where secrets go: it is never editable from the dashboard; change it in the console (ahub settings edit config.local.json set ${tier3[0]} <json>)` };
100
+ if (tier3.length > 1) return { ok: false, error: "change one executable or secret-locating key at a time: each needs its own typed confirmation" };
101
+ return { ok: true, tier3 };
102
+ }
103
+ function planned(raw: unknown, authority: EditAuthority): { ok: true; plan: Extract<ConfigEditPlan, { ok: true }> } | { ok: false; error: string; field?: string } {
104
+ const allowed = authorize(raw, authority);
105
+ if (!allowed.ok) return allowed;
106
+ const held = host.held();
107
+ if (held) return { ok: false, error: `${held} is holding config edits` };
108
+ const plan = planConfigEdit(host.cwd, raw, host.check);
109
+ if (!plan.ok) return plan;
110
+ // The plan reads the file itself: if it finds a tier 3 key the request alone did not show, a dashboard still learns nothing.
111
+ if (plan.tier === 3 && authority !== "terminal") return { ok: false, error: `${plan.tier3Keys[0]} chooses what the hub runs or where secrets go: it is never editable from the dashboard; change it in the console` };
112
+ // The request names some tier 3 keys and the file adds others (an instance it holds a home for): one write changes exactly one, and each is typed.
113
+ if (plan.tier3Keys.length > 1) return { ok: false, error: "change one executable or secret-locating key at a time: each needs its own typed confirmation" };
114
+ return { ok: true, plan };
115
+ }
116
+ function diffText(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
117
+ const show = (path: string, value: unknown): string => { const masked = maskTier3(path.split("."), value); return masked === undefined ? "(unset)" : JSON.stringify(masked); };
118
+ return plan.diff.map(row => `${row.path}: ${show(row.path, row.before)} -> ${show(row.path, row.after)} (${row.applies === "restart" ? "pending restart" : "applies at next use"})`);
119
+ }
120
+ function preview(raw: unknown, authority: EditAuthority): Record<string, unknown> {
121
+ const result = planned(raw, authority);
122
+ if (!result.ok) return { ok: false, error: result.error, ...(result.field ? { field: result.field } : {}) };
123
+ const { plan } = result;
124
+ return { ok: true, file: plan.request.file, revision: plan.request.revision, tier: plan.tier, lines: [...diffText(plan), ...plan.notes],
125
+ diff: plan.diff.map(row => ({ path: row.path, tier: row.tier, applies: row.applies, before: maskTier3(row.path.split("."), row.before) ?? null, after: maskTier3(row.path.split("."), row.after) ?? null })),
126
+ ...(plan.tier === 3 ? { confirm: plan.tier3Keys[0], warning: "this runs code with the daemon's rights, or chooses where secrets are read from" } : {}) };
127
+ }
128
+ async function apply(raw: unknown, authority: EditAuthority, confirm?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
129
+ let reason: string | undefined;
130
+ if (rawReason !== undefined) {
131
+ if (typeof rawReason !== "string" || !rawReason.trim() || Buffer.byteLength(rawReason) > REASON_BYTES) return { ok: false, error: `reason must contain 1..${REASON_BYTES} UTF-8 bytes` };
132
+ reason = rawReason.trim().replace(/[\x00-\x1f\x7f]/g, " ");
133
+ }
134
+ const result = planned(raw, authority);
135
+ if (!result.ok) return { ok: false, error: result.error, ...(result.field ? { field: result.field } : {}) };
136
+ const { plan } = result;
137
+ if (plan.tier === 3 && confirm !== plan.tier3Keys[0]) return { ok: false, error: `${plan.tier3Keys[0]} runs code with the daemon's rights or locates secrets: type the key name to confirm; nothing was written`, confirm: plan.tier3Keys[0] };
138
+ let written: ReturnType<typeof writeConfigEdit>;
139
+ try { written = writeConfigEdit(host.cwd, host.stateDir, plan); }
140
+ catch (error) { return { ok: false, error: `${(error as Error).message}; nothing was changed` }; }
141
+ const source = authority === "terminal" ? "terminal" as const : "dashboard" as const;
142
+ host.event({ type: "config_edit", file: plan.request.file, paths: written.paths, fileBefore: written.fileBefore, fileAfter: written.fileAfter, tier: plan.tier, by: "person", source, ...(written.backup ? { backup: written.backup } : {}), ...(reason ? { reason } : {}) });
143
+ try { host.notify(`config edit: ${plan.request.file} ${plan.diff.map(row => row.path).join(", ")} (${source})`); } catch { /* the write stands */ }
144
+ const settled = () => { try { host.changed(); } catch { /* the write stands; the next status write shows it */ } };
145
+ if (plan.request.file === "sdlc.toml") { settled(); return { ok: true, saved: true, ...(written.backup ? { backup: written.backup } : {}), lines: diffText(plan), text: `sdlc.toml written; the lifecycle is read once at hub start: restart pending`, restartPending: allPending() }; }
146
+ const failures: string[] = [];
147
+ for (const key of leafKeys(plan)) {
148
+ let reloaded: Record<string, unknown>;
149
+ try { reloaded = await host.reload(authority, key); } catch { reloaded = { ok: false, error: "the change was saved but could not be published; inspect hub.log" }; }
150
+ if (reloaded.ok !== true) failures.push(`${key}: ${String(reloaded.error ?? "not published")}`);
151
+ }
152
+ settled();
153
+ if (failures.length) return { ok: false, saved: true, error: `saved, but not published: ${failures.join("; ")}; run ahub settings reload to review it`, ...(written.backup ? { backup: written.backup } : {}) };
154
+ return { ok: true, saved: true, ...(written.backup ? { backup: written.backup } : {}), lines: diffText(plan), text: `config.local.json written: ${plan.diff.map(row => row.path).join(", ")}${plan.diff.some(row => row.applies === "restart") ? "; restart pending" : ""}`, restartPending: allPending() };
155
+ }
156
+ return { view, preview, apply, pendingRestart };
157
+ }
158
+
159
+ /** The leaf keys a plan changed, so each is published through its own scoped reload. */
160
+ function leafKeys(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
161
+ const out = new Set<string>();
162
+ const walk = (prefix: string, before: unknown, after: unknown): void => {
163
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
164
+ if (object(before) || object(after)) {
165
+ const old = object(before) ? before : {}, next = object(after) ? after : {};
166
+ for (const key of new Set([...Object.keys(old), ...Object.keys(next)])) walk(`${prefix}.${key}`, old[key], next[key]);
167
+ return;
168
+ }
169
+ if (JSON.stringify(before ?? null) !== JSON.stringify(after ?? null)) out.add(prefix);
170
+ };
171
+ for (const row of plan.diff) walk(row.path, row.before, row.after);
172
+ return [...out];
173
+ }
@@ -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.