@staix/agent-hub 0.16.0 → 0.18.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 (96) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +20 -5
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +5 -3
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +9 -3
  9. package/docs/agent-notes/tests.md +6 -3
  10. package/docs/dogfood.md +21 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +150 -34
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +14 -4
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +39 -13
  17. package/docs/security.md +41 -9
  18. package/docs/smoke.md +51 -3
  19. package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  22. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  23. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  24. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  25. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  26. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  27. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  28. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  29. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  30. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  31. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  32. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  33. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  34. package/docs/verified.json +28 -19
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +1115 -192
  38. package/src/adapters/codex-review-isolation.ts +10 -1
  39. package/src/adapters/compat.ts +7 -2
  40. package/src/adapters/conformance/enrollment.ts +18 -4
  41. package/src/adapters/conformance/factories.ts +4 -1
  42. package/src/adapters/conformance/kit.ts +11 -0
  43. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  46. package/src/adapters/definitions/native-factories.ts +8 -0
  47. package/src/adapters/definitions/native-recovery.ts +8 -0
  48. package/src/adapters/definitions/qwen.ts +7 -4
  49. package/src/adapters/local-worker.ts +5 -3
  50. package/src/adapters/qwen-dual.ts +578 -0
  51. package/src/adapters/registry-types.ts +5 -1
  52. package/src/adapters/registry.ts +9 -1
  53. package/src/adapters/reviewer-mcp.ts +12 -6
  54. package/src/adapters/reviewer-print.ts +100 -17
  55. package/src/cli/console-state.ts +9 -2
  56. package/src/cli/help.ts +29 -28
  57. package/src/cli/identity.ts +87 -6
  58. package/src/cli/main.ts +190 -69
  59. package/src/cli/output.ts +7 -2
  60. package/src/cli/sdlc-output.ts +242 -0
  61. package/src/cli/task-output.ts +212 -0
  62. package/src/cli/upgrade-output.ts +57 -0
  63. package/src/cli/upgrade.ts +4 -3
  64. package/src/hub/board.ts +17 -0
  65. package/src/hub/check-runner.ts +252 -22
  66. package/src/hub/conductor.ts +5 -0
  67. package/src/hub/config-apply.ts +16 -7
  68. package/src/hub/config-edit-host.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -1
  70. package/src/hub/daemon.ts +487 -94
  71. package/src/hub/dogfood-profile.ts +248 -0
  72. package/src/hub/dogfood-read.ts +77 -14
  73. package/src/hub/dogfood.ts +5 -1
  74. package/src/hub/events.ts +1 -1
  75. package/src/hub/host-executable.ts +2 -1
  76. package/src/hub/hub-tools.ts +2 -2
  77. package/src/hub/memory-plan-guard.ts +8 -33
  78. package/src/hub/operational-authority.ts +60 -0
  79. package/src/hub/operational-view.ts +141 -0
  80. package/src/hub/orphan-process.ts +69 -0
  81. package/src/hub/permission-mode.ts +1 -1
  82. package/src/hub/read-only-shell-proof.ts +80 -0
  83. package/src/hub/read-only-shell.ts +162 -0
  84. package/src/hub/reviewer-pool-config.ts +3 -2
  85. package/src/hub/reviewer-pool.ts +3 -0
  86. package/src/hub/routing.ts +23 -6
  87. package/src/hub/sdlc-merge.ts +299 -0
  88. package/src/hub/sdlc-mirror.ts +129 -0
  89. package/src/hub/sdlc.ts +58 -12
  90. package/src/hub/settings.ts +11 -5
  91. package/src/hub/snapshots.ts +60 -0
  92. package/src/hub/task-repository.ts +31 -5
  93. package/src/hub/tasks.ts +266 -28
  94. package/src/local/tools.ts +17 -4
  95. package/src/memory/brief.ts +45 -5
  96. package/src/ui/index.html +2 -1
@@ -1,9 +1,12 @@
1
+ import { Readable } from "node:stream";
1
2
  import { spawn } from "node:child_process";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { constants, closeSync, fstatSync, openSync, readFileSync } from "node:fs";
4
- import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
5
+ import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
5
7
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
8
  import { hubGitEnv, hubGitSync } from "./git.ts";
9
+ import { stopOwnedProcess, trackGroup } from "./child-process.ts";
7
10
  import { hostExecutable } from "./host-executable.ts";
8
11
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
9
12
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
@@ -21,7 +24,7 @@ export interface CheckRequest {
21
24
  }
22
25
  export interface RunReceipt extends CheckRequest {
23
26
  run: string; sha: string; tree: string; lockfile: string; excluded: number; dependency_files: number;
24
- runner: "container"; image: string; platform: string; container: string;
27
+ runner: "container" | "remote"; image: string; platform: string; container: string;
25
28
  hub_pid: number; hub_signature: string; created: number;
26
29
  status: "queued" | "running" | "finished" | "stale";
27
30
  exit: number | null; timed_out: boolean; cancelled: boolean; interrupted: boolean;
@@ -29,9 +32,9 @@ export interface RunReceipt extends CheckRequest {
29
32
  error?: string;
30
33
  }
31
34
  export interface RunHandle { run: string; done: Promise<RunReceipt> }
32
- export interface CommandResult { code: number | null; stdout: string }
35
+ export interface CommandResult { code: number | null; stdout: string; outputBytes?: number }
33
36
  /** Fixed-argv transport seam. The production implementation below is the only container spawner. */
34
- export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void }) => Promise<CommandResult>;
37
+ export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void; input?: ReadableStream<Uint8Array> }) => Promise<CommandResult>;
35
38
  export interface RunnerOptions {
36
39
  projectDir: string; stateDir: string; storageDir: string; projectKey: string;
37
40
  enabled: boolean | (() => boolean); isDenied: (path: string) => boolean;
@@ -39,10 +42,25 @@ export interface RunnerOptions {
39
42
  /** Daemon authority fence, re-evaluated across every asynchronous stage. */
40
43
  isCurrent?: (request: CheckRequest) => boolean;
41
44
  /** Test seams; never supplied from a tool request or config. */
42
- command?: ContainerCommand; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
45
+ command?: ContainerCommand; remote?: RemoteRunnerConfig; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
43
46
  schedule?: (callback: () => void, ms: number) => () => void;
44
47
  now?: () => number; liveness?: typeof processLiveness; signature?: typeof processSignature;
45
48
  }
49
+ export interface RemoteRunnerConfig { host: string; image: string; root: string }
50
+ /** Only the backend mechanics vary; task authority, staging, receipts and leases remain one lifecycle. */
51
+ interface RunnerTransport {
52
+ command: ContainerCommand;
53
+ kind: "remote"; image: string; platform: string;
54
+ probe(command: ContainerCommand, cleanup: ContainerCommand): Promise<RunnerState>;
55
+ isolate(command: ContainerCommand): Promise<void>;
56
+ runArgs(receipt: RunReceipt, labels: string[]): string[];
57
+ copy(command: ContainerCommand, source: string, container: string, signal: AbortSignal): Promise<void>;
58
+ execArgs(container: string, words: readonly string[]): string[];
59
+ cleanup(command: ContainerCommand, name: string): Promise<boolean>;
60
+ listArgs: string[];
61
+ list(stdout: string): unknown[];
62
+ redact(text: string): string;
63
+ }
46
64
  interface Entry { receipt: RunReceipt; resolve: (receipt: RunReceipt) => void; abort: AbortController }
47
65
  function safePath(path: string): boolean {
48
66
  return !!path && !isAbsolute(path) && !path.includes("\\") && !path.includes("\0") && path.split("/").every(p => p !== ".." && p !== "." && p !== "");
@@ -108,9 +126,9 @@ export class ContainerRunner {
108
126
  private readonly records: string;
109
127
  private readonly root: string;
110
128
  private readonly retentionTimer: ReturnType<typeof setInterval>;
111
- constructor(private readonly options: RunnerOptions) {
129
+ constructor(private readonly options: RunnerOptions, private readonly transport?: RunnerTransport) {
112
130
  if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
113
- this.command = options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
131
+ this.command = transport?.command ?? options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
114
132
  this.now = options.now ?? Date.now;
115
133
  this.signature = (options.signature ?? processSignature)(process.pid) ?? "";
116
134
  this.records = join(options.stateDir, "checks");
@@ -153,6 +171,15 @@ export class ContainerRunner {
153
171
  if (this.closing || signal.aborted) throw new Error("check stopped");
154
172
  return result;
155
173
  };
174
+ if (this.transport) {
175
+ value = await this.transport.probe(async (args, options) => {
176
+ if (this.closing || signal.aborted) throw new Error("check stopped");
177
+ const result = await this.command(args, { ...options, signal, timeoutMs: 15_000 });
178
+ if (this.closing || signal.aborted) throw new Error("check stopped");
179
+ return result;
180
+ }, (args, options) => this.command(args, { ...options, timeoutMs: 15_000 }));
181
+ if (this.closing || signal.aborted) throw new Error("check stopped");
182
+ } else {
156
183
  const version = await probe(["--version"]);
157
184
  const service = await probe(["system", "status"]);
158
185
  const image = await probe(["image", "inspect", CHECK_IMAGE]);
@@ -164,7 +191,8 @@ export class ContainerRunner {
164
191
  : network.code === 0 && !isolatedNetwork(network.stdout)
165
192
  ? { available: false, reason: "network isolation unverified" }
166
193
  : { available: true, platform: `linux/arm64 (apple-container ${version.stdout.trim().slice(0, 80)})` };
167
- } catch { if (this.closing || signal.aborted) return { available: false, reason: this.closing ? "stopping" : "check stopped" }; value = { available: false, reason: "container unavailable" }; }
194
+ }
195
+ } catch { if (this.closing || signal.aborted) return { available: false, reason: this.closing ? "stopping" : "check stopped" }; value = { available: false, reason: this.transport ? "remote runner unavailable" : "container unavailable" }; }
168
196
  const checkedAt = this.now();
169
197
  value.checkedAt = checkedAt;
170
198
  this.availableCache = { at: checkedAt, value };
@@ -179,7 +207,7 @@ export class ContainerRunner {
179
207
  if (this.entries.size >= 3) throw new PreEffectToolRefusal("busy");
180
208
  const run = randomUUID();
181
209
  const receipt: RunReceipt = { ...request, files: request.files ? [...request.files] : undefined, run, sha: "", tree: "", lockfile: "", excluded: 0, dependency_files: 0,
182
- runner: "container", image: CHECK_IMAGE, platform: "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
210
+ runner: this.transport?.kind ?? "container", image: this.transport?.image ?? CHECK_IMAGE, platform: this.transport?.platform ?? "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
183
211
  timed_out: false, cancelled: false, interrupted: false, duration_ms: 0, tail: "", output_bytes: 0, truncated: false, output_withheld: false };
184
212
  let resolveResult!: (receipt: RunReceipt) => void;
185
213
  const done = new Promise<RunReceipt>(r => { resolveResult = r; });
@@ -282,13 +310,23 @@ export class ContainerRunner {
282
310
  if (data.status !== 0 || data.error) throw new Error("source blob unavailable");
283
311
  const blob = Buffer.from(data.stdout);
284
312
  bytes += blob.length; if (bytes > 512 * 1024 * 1024) throw new Error("source exceeds byte limit");
285
- const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: entry.mode === "100755" ? 0o700 : 0o600 }); check(); paths.add(entry.path);
313
+ const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: this.transport ? entry.mode === "100755" ? 0o755 : 0o644 : entry.mode === "100755" ? 0o700 : 0o600 });
314
+ if (this.transport) {
315
+ // chmod is intentional: the hub may have umask 077, but the remote non-root uid must traverse/read.
316
+ await chmod(dest, entry.mode === "100755" ? 0o755 : 0o644);
317
+ for (let directory = dirname(dest); directory !== dirname(src); directory = dirname(directory)) await chmod(directory, 0o755);
318
+ }
319
+ check(); paths.add(entry.path);
286
320
  }
287
321
  if (receipt.files?.some(file => !paths.has(file))) throw new Error("test file unavailable in snapshot");
288
322
  if (!paths.has("bun.lock")) throw new Error("lockfile unavailable");
289
323
  const lock = await readFile(join(src, "bun.lock")); check();
290
- const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
291
324
  receipt.lockfile = hash(lock);
325
+ if (this.transport) {
326
+ // Source only: Mac dependencies must never be transported to a Linux runner.
327
+ await chmod(src, 0o755); check(); return;
328
+ }
329
+ const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
292
330
  if (receipt.lockfile !== hash(hostLock)) throw new Error("lockfile differs; dependencies unavailable");
293
331
  const deps = join(this.options.projectDir, "node_modules");
294
332
  const dest = join(src, "node_modules");
@@ -333,7 +371,8 @@ export class ContainerRunner {
333
371
  try {
334
372
  this.ensureCurrent(entry);
335
373
  await this.save(r); this.ensureCurrent(entry);
336
- const state = await this.available(entry.abort.signal); this.ensureCurrent(entry); if (!state.available) throw new Error(`runner unavailable: ${state.reason}`);
374
+ const state = await this.available(entry.abort.signal); this.ensureCurrent(entry);
375
+ if (!state.available) { if (this.transport && state.reason === "remote runner unavailable") throw new RemoteTransportError(); throw new Error(`runner unavailable: ${state.reason}`); }
337
376
  r.platform = state.platform!;
338
377
  release = await this.heavy(entry); this.ensureCurrent(entry);
339
378
  if (entry.abort.signal.aborted) throw new Error("cancelled");
@@ -346,43 +385,54 @@ export class ContainerRunner {
346
385
  if (!entry.abort.signal.aborted && this.now() >= deadline) expire();
347
386
  if (entry.abort.signal.aborted) throw new Error("check stopped");
348
387
  };
349
- const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void) => {
388
+ const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void, input?: ReadableStream<Uint8Array>) => {
350
389
  checkRunning();
351
- const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}) });
390
+ const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}), ...(input ? { input } : {}) });
352
391
  checkRunning(); // A transport may return success after cancellation; it cannot authorize another stage.
353
392
  return result;
354
393
  };
394
+ await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); checkRunning();
355
395
  await this.snapshot(r, src, entry.abort.signal, checkRunning);
356
396
  this.ensureCurrent(entry);
357
397
  if (entry.abort.signal.aborted) throw new Error("cancelled");
398
+ const transportCommand: ContainerCommand = (args, options) => command(args, options?.timeoutMs, options?.output, options?.input);
399
+ if (this.transport) await this.transport.isolate(transportCommand);
400
+ else {
358
401
  const network = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
359
402
  if (network.code !== 0 && (await command(["network", "create", "--internal", NETWORK])).code !== 0) throw new Error("runner unavailable: internal network unavailable");
360
403
  this.ensureCurrent(entry);
361
404
  // Refuse an existing non-internal network rather than trusting its name.
362
405
  const info = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
363
406
  if (info.code !== 0 || !isolatedNetwork(info.stdout)) throw new Error("runner unavailable: network isolation unverified");
407
+ }
364
408
  r.status = "running"; await this.save(r); this.ensureCurrent(entry);
365
409
  attemptedContainer = true;
366
410
  // Cold VM startup and a <=1.5 GiB source/dependency transfer need their own bounded allowances.
367
- const run = await command(["run", "--detach", "--rm", "--name", r.container, "--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
411
+ const labels = ["--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`];
412
+ const run = await command(this.transport?.runArgs(r, labels) ?? ["run", "--detach", "--rm", "--name", r.container, ...labels, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
368
413
  this.ensureCurrent(entry);
369
- if (run.code !== 0) throw new Error("runner unavailable: container start failed");
370
- const copy = await command(["cp", `${src}/.`, `${r.container}:/work`], 180_000);
414
+ if (run.code !== 0) { if (this.transport) throw new RemoteTransportError(); throw new Error("runner unavailable: container start failed"); }
415
+ if (this.transport) await this.transport.copy(transportCommand, src, r.container, entry.abort.signal);
416
+ else {
417
+ const copy = await command(["cp", `${src}/.`, `${r.container}:/work`], 180_000);
418
+ if (copy.code !== 0) throw new Error("container source transfer failed");
419
+ }
371
420
  this.ensureCurrent(entry);
372
- if (copy.code !== 0) throw new Error("container source transfer failed");
373
421
  if (entry.abort.signal.aborted) throw new Error("cancelled");
374
422
  const words = r.check === "test" ? ["bun", "test", ...r.files!] : ["bun", "x", "--no-install", "tsc", "--noEmit"];
375
- const result = await command(["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
423
+ const result = await command(this.transport?.execArgs(r.container, words) ?? ["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
376
424
  r.output_bytes += data.length;
377
425
  if (output.length < MAX_OUTPUT) output = Buffer.concat([output, data.subarray(0, MAX_OUTPUT - output.length)]);
378
426
  outputTail = Buffer.concat([outputTail, data]).subarray(-16_384);
379
427
  r.truncated = r.output_bytes > MAX_OUTPUT;
380
428
  });
381
429
  this.ensureCurrent(entry);
430
+ if (this.transport && result.code === 255) { r.output_withheld = true; throw new RemoteTransportError(); }
431
+ if (result.outputBytes !== undefined) { r.output_bytes = result.outputBytes; r.truncated = r.output_bytes > MAX_OUTPUT; }
382
432
  r.exit = entry.abort.signal.aborted ? null : result.code;
383
433
  } catch (error) {
384
434
  const code = (error as NodeJS.ErrnoException)?.code;
385
- r.error = entry.abort.signal.aborted ? "check stopped" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
435
+ r.error = entry.abort.signal.aborted ? "check stopped" : error instanceof RemoteTransportError ? "remote runner unavailable" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
386
436
  }
387
437
  finally {
388
438
  cancelDeadline?.();
@@ -391,8 +441,11 @@ export class ContainerRunner {
391
441
  if (!removed) { r.error = "container cleanup unconfirmed; heavy slot retained"; r.exit = null; }
392
442
  await rm(src, { recursive: true, force: true }).catch(() => {});
393
443
  if (release && removed) await release().catch(() => {});
394
- r.duration_ms = Math.max(0, this.now() - begun); r.tail = tail(outputTail.toString("utf8"), r.output_bytes > outputTail.length); if (r.status !== "stale") r.status = "finished";
444
+ const rawTail = outputTail.toString("utf8"), safeTail = this.transport?.redact(rawTail) ?? rawTail;
445
+ r.output_withheld ||= safeTail !== rawTail;
446
+ r.duration_ms = Math.max(0, this.now() - begun); r.tail = tail(safeTail, r.output_bytes > outputTail.length); if (r.status !== "stale") r.status = "finished";
395
447
  if (!this.current(r)) { r.status = "stale"; }
448
+ if (this.transport) { const raw = output.toString("utf8"), safe = this.transport.redact(raw); r.output_withheld ||= safe !== raw; output = Buffer.from(safe); }
396
449
  try { await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, "output.log"), r.truncated ? Buffer.concat([output.subarray(0, MAX_OUTPUT - 7), Buffer.from("\n[cut]\n")]) : output, { mode: 0o600 }); await this.save(r); } catch { r.error = "check receipt storage unavailable"; r.tail = ""; r.output_withheld = true; }
397
450
  await this.maintain(); // Maintenance never changes an outcome already saved for this run.
398
451
  if (!this.current(r)) { r.status = "stale"; await this.save(r).catch(() => {}); }
@@ -400,6 +453,7 @@ export class ContainerRunner {
400
453
  }
401
454
  }
402
455
  private async cleanup(name: string): Promise<boolean> {
456
+ if (this.transport) return this.transport.cleanup(this.command, name);
403
457
  await this.command(["kill", name]).catch(() => {});
404
458
  await this.command(["rm", name]).catch(() => {});
405
459
  try {
@@ -458,13 +512,18 @@ export class ContainerRunner {
458
512
  if (!this.enabled()) { await this.maintain(); return unknown; }
459
513
  try {
460
514
  let containers: unknown;
461
- try { const list = await this.command(["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = JSON.parse(list.stdout); } catch { return unknown; }
515
+ try { const list = await this.command(this.transport?.listArgs ?? ["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = this.transport?.list(list.stdout) ?? JSON.parse(list.stdout); } catch { return unknown; }
462
516
  if (!Array.isArray(containers)) return unknown;
463
517
  for (const container of containers) {
464
518
  const c = container as { id?: string; configuration?: { id?: string; labels?: Record<string, string> }; labels?: Record<string, string> };
465
519
  const labels = c.configuration?.labels ?? c.labels;
466
520
  if (labels?.["agenthub.project"] !== this.options.projectKey) continue;
467
521
  const name = c.configuration?.id ?? c.id;
522
+ const probe = labels["agenthub.probe"];
523
+ // Probes have no task receipt and self-expire after the fixed 600-second idle process.
524
+ // Recognizing their exact shape is no authority to kill them or other unknown owners.
525
+ if (this.transport && typeof probe === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(probe)
526
+ && name === `agenthub-check-probe-${probe}` && !labels["agenthub.run"]) continue;
468
527
  const run = labels["agenthub.run"];
469
528
  if (!name || !run || name !== `agenthub-check-${run}`) { unknown.push("orphan check: unknown owner"); continue; }
470
529
  let r: RunReceipt | undefined;
@@ -512,3 +571,174 @@ export class ContainerRunner {
512
571
  }
513
572
  }
514
573
  }
574
+
575
+ /** Machine-local data only; every failure is value-free so host/account names never enter diagnostics. */
576
+ export function remoteRunnerConfig(value: unknown): RemoteRunnerConfig {
577
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote runner configuration");
578
+ const raw = value as Record<string, unknown>;
579
+ if (Object.keys(raw).sort().join(",") !== "host,image,root"
580
+ || typeof raw.host !== "string" || raw.host.length > 288 || !/^(?:[a-z_][a-z0-9_-]{0,31}@)?[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(raw.host)
581
+ || typeof raw.image !== "string" || !/^[a-z0-9][a-z0-9./:_-]{0,200}@sha256:[a-f0-9]{64}$/.test(raw.image)
582
+ || typeof raw.root !== "string" || raw.root.length > 1024 || !/^(?:\/[A-Za-z0-9._-]+)+\/?$/.test(raw.root) || raw.root.split("/").some(part => part === "." || part === "..")) throw new Error("invalid remote runner configuration");
583
+ return { host: raw.host, image: raw.image, root: raw.root };
584
+ }
585
+ /** OpenSSH joins its remote argv into a shell command. Quote every token, including fixed Docker arguments. */
586
+ export function remoteDockerArgs(host: string, args: readonly string[], mergeStderr = false): string[] {
587
+ const quote = (word: string) => `'${word.replaceAll("'", `'"'"'`)}'`;
588
+ return ["-T", "-a", "-x", "-o", "ClearAllForwardings=yes", "-o", "PermitLocalCommand=no", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "--", host, `docker ${args.map(quote).join(" ")}${args[0] === "exec" && mergeStderr ? " 2>&1" : ""}`];
589
+ }
590
+ /** Darwin's system tar is bsdtar even when a GNU tar shim comes first on PATH. */
591
+ export function remoteArchiveArgs(projectDir: string, source: string, platform: NodeJS.Platform = process.platform): string[] {
592
+ const executable = platform === "darwin" ? "/usr/bin/tar" : hostExecutable("tar", projectDir);
593
+ const metadata = platform === "darwin"
594
+ ? ["--format=gnutar", "--no-xattrs", "--no-acls", "--no-fflags", "--disable-copyfile", "--uid", "65532", "--gid", "65532", "--uname", "check", "--gname", "check"]
595
+ : ["--format=gnu", "--no-xattrs", "--no-acls", "--owner=65532", "--group=65532", "--numeric-owner"];
596
+ return [executable, ...metadata, "-cf", "-", "-C", source, "."];
597
+ }
598
+ function runRemoteDocker(executable: string, remote: RemoteRunnerConfig, args: string[], options: Parameters<ContainerCommand>[1] = {}): Promise<CommandResult> {
599
+ return new Promise((resolveResult, reject) => {
600
+ if (options.signal?.aborted) { reject(new Error("remote runner unavailable")); return; }
601
+ // The hub's agent socket authenticates this client; -a prevents forwarding it into the remote job.
602
+ const env = { PATH: dirname(executable), HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK };
603
+ const child = spawn(executable, remoteDockerArgs(remote.host, args, options.output !== undefined), { env, detached: true, stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"] });
604
+ let stdout = "", inputFailed = false, bytes = 0, first = Buffer.alloc(0), last = Buffer.alloc(0);
605
+ let stopping: Promise<void> | undefined;
606
+ const stop = () => stopping ??= stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 });
607
+ const abort = () => { void stop().catch(() => {}); };
608
+ child.once("spawn", () => { trackGroup(child); if (options.signal?.aborted) abort(); });
609
+ const timer = setTimeout(abort, Math.max(1, Math.min(600_000, options.timeoutMs ?? 15_000)));
610
+ options.signal?.addEventListener("abort", abort, { once: true });
611
+ if (options.signal?.aborted) abort();
612
+ const input = options.input ? Readable.fromWeb(options.input as import("node:stream/web").ReadableStream<Uint8Array>) : undefined;
613
+ input?.on("error", () => { inputFailed = true; abort(); }); child.stdin?.on("error", () => { inputFailed = true; input?.destroy(); }); if (input && child.stdin) input.pipe(child.stdin);
614
+ child.stdout!.on("data", (data: Buffer) => {
615
+ bytes += data.length;
616
+ if (options.output) {
617
+ if (first.length < MAX_OUTPUT) first = Buffer.concat([first, data.subarray(0, MAX_OUTPUT - first.length)]);
618
+ last = Buffer.concat([last, data]).subarray(-16_384);
619
+ } else stdout = (stdout + data.toString()).slice(-MAX_OUTPUT);
620
+ });
621
+ // SSH diagnostics mix account/host paths with remote stderr. Never forward that channel as task output.
622
+ child.stderr!.resume();
623
+ const finish = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); input?.destroy(); };
624
+ child.once("error", () => { finish(); reject(new Error("remote runner unavailable")); });
625
+ child.once("close", async code => {
626
+ finish();
627
+ try { await stop(); } catch { reject(new Error("remote runner unavailable")); return; }
628
+ if (code === 255 || options.signal?.aborted) return resolveResult({ code, stdout: "", outputBytes: 0 });
629
+ if (options.output) { options.output(first); if (bytes > first.length) options.output(last); }
630
+ resolveResult({ code: inputFailed ? 1 : code, stdout, ...(options.output ? { outputBytes: bytes } : {}) });
631
+ });
632
+ });
633
+ }
634
+ /** Distinguish transport failures from deterministic request/snapshot refusals without leaking remote stderr. */
635
+ class RemoteTransportError extends Error { constructor() { super("remote runner unavailable"); } }
636
+
637
+ /** Same production strategy for transport-only component/native checks; production uses RemoteRunner's dependency gate. */
638
+ export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
639
+ if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
640
+ const remote = remoteRunnerConfig(options.remote), digest = remote.image.slice(remote.image.indexOf("@") + 1);
641
+ const command: ContainerCommand = async (args, opts) => {
642
+ try { return await (options.command ? options.command(args, opts) : runRemoteDocker(hostExecutable("ssh", options.projectDir), remote, args, opts)); }
643
+ catch { throw new RemoteTransportError(); }
644
+ };
645
+ const privateValues = [...new Set([remote.host, ...remote.host.split("@"), remote.root])].sort((a, b) => b.length - a.length);
646
+ const redact = (text: string) => {
647
+ for (const value of privateValues) {
648
+ text = text.replaceAll(value, "***");
649
+ for (let length = Math.min(value.length - 1, text.length); length > 0; length--) if (text.endsWith(value.slice(0, length))) { text = text.slice(0, -length) + "***"; break; }
650
+ }
651
+ return text;
652
+ };
653
+ const format = '{"id":{{json .Names}},"project":{{json (.Label "agenthub.project")}},"hub":{{json (.Label "agenthub.hub")}},"run":{{json (.Label "agenthub.run")}},"probe":{{json (.Label "agenthub.probe")}}}';
654
+ const inventoryArgs = (name?: string, project = true) => ["ps", "--all", "--no-trunc", ...(project ? ["--filter", `label=agenthub.project=${options.projectKey}`] : []), ...(name ? ["--filter", `name=^/${name}$`] : []), "--format", format];
655
+ const listArgs = inventoryArgs();
656
+ const list = (stdout: string): { id: string; labels: Record<string, string> }[] => {
657
+ const lines = stdout.trim() ? stdout.trim().split("\n") : [];
658
+ if (lines.length > 10_000) throw new RemoteTransportError();
659
+ return lines.map(line => {
660
+ let value: Record<string, unknown>;
661
+ try { value = JSON.parse(line) as Record<string, unknown>; } catch { throw new RemoteTransportError(); }
662
+ if (!["id", "project", "hub", "run", "probe"].every(key => typeof value[key] === "string")) throw new RemoteTransportError();
663
+ return { id: value.id as string, labels: { "agenthub.project": value.project as string, "agenthub.hub": value.hub as string, "agenthub.run": value.run as string, "agenthub.probe": value.probe as string } };
664
+ });
665
+ };
666
+ const cleanup = async (command: ContainerCommand, name: string): Promise<boolean> => {
667
+ if (!/^agenthub-check-(?:probe-)?[a-f0-9-]{36}$/.test(name)) return false;
668
+ try {
669
+ const before = await command(inventoryArgs(name));
670
+ if (before.code !== 0) return false;
671
+ const owned = list(before.stdout).filter(row => row.id === name && row.labels["agenthub.project"] === options.projectKey);
672
+ if (owned.length > 1) return false;
673
+ if (owned.length === 1) { await command(["kill", name]).catch(() => {}); await command(["rm", name]).catch(() => {}); }
674
+ // Exact-name absence, even if a same-name container has different labels; never kill that other owner.
675
+ const after = await command(inventoryArgs(name, false));
676
+ return after.code === 0 && !list(after.stdout).some(row => row.id === name);
677
+ } catch { return false; }
678
+ };
679
+ const hardening = ["--pull", "never", "--network", "none", "--user", "65532:65532", "--read-only", "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,mode=1777", "--tmpfs", "/home/check:rw,nosuid,nodev,size=16m,uid=65532,gid=65532", "--tmpfs", "/work:rw,nosuid,nodev,size=512m,mode=0755,uid=65532,gid=65532", "--env", "HOME=/home/check", "--env", "TMPDIR=/tmp", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "64", "--cpus", "4", "--memory", "4g"];
680
+ const execArgs = (container: string, words: readonly string[]) => ["exec", "--user", "65532:65532", "--workdir", "/work", container, ...words];
681
+ const copy = async (command: ContainerCommand, source: string, container: string, signal: AbortSignal) => {
682
+ try {
683
+ const argv = remoteArchiveArgs(options.projectDir, source);
684
+ const archive = Bun.spawn(argv, { env: { PATH: dirname(argv[0]!), COPYFILE_DISABLE: "1" }, stdout: "pipe", stderr: "ignore" });
685
+ const abort = () => { if (archive.exitCode === null) try { archive.kill("SIGKILL"); } catch { /* Exit promise confirms this owned producer. */ } };
686
+ const timer = setTimeout(abort, 180_000);
687
+ signal.addEventListener("abort", abort, { once: true }); if (signal.aborted) abort();
688
+ try {
689
+ // docker cp cannot write a read-only container filesystem, even where a writable tmpfs is mounted.
690
+ const result = await command(["exec", "-i", "--user", "65532:65532", container, "tar", "--no-same-owner", "-xf", "-", "-C", "/work"], { timeoutMs: 180_000, input: archive.stdout });
691
+ if (result.code !== 0 || await archive.exited !== 0) throw new RemoteTransportError();
692
+ } finally { clearTimeout(timer); signal.removeEventListener("abort", abort); abort(); await archive.exited; }
693
+ } catch { throw new RemoteTransportError(); }
694
+ };
695
+ return {
696
+ command, kind: "remote", image: digest, platform: "linux/unknown (remote docker unknown)", listArgs, list, redact, execArgs, cleanup, copy,
697
+ async probe(command, teardown) {
698
+ let probeRoot: string | undefined;
699
+ try {
700
+ const version = await command(["version", "--format", "{{json .Server}}"]);
701
+ if (version.code !== 0) throw new RemoteTransportError();
702
+ const server = JSON.parse(version.stdout) as { Version?: string; Os?: string; Arch?: string };
703
+ if (server.Os !== "linux" || !["amd64", "arm64"].includes(server.Arch ?? "") || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(server.Version ?? "")) throw new RemoteTransportError();
704
+ const image = await command(["image", "inspect", remote.image, "--format", '{"digests":{{json .RepoDigests}},"arch":{{json .Architecture}},"os":{{json .Os}}}']);
705
+ const metadata = image.code === 0 ? JSON.parse(image.stdout) as { digests?: unknown; arch?: string; os?: string } : undefined;
706
+ if (metadata?.os !== "linux" || !["amd64", "arm64"].includes(metadata.arch ?? "") || !Array.isArray(metadata.digests) || !metadata.digests.some(value => typeof value === "string" && value.endsWith(`@${digest}`))) throw new RemoteTransportError();
707
+ let architecture: string | undefined;
708
+ const probeId = randomUUID(), name = `agenthub-check-probe-${probeId}`;
709
+ probeRoot = await mkdtemp(join(tmpdir(), "ahub-remote-probe-"));
710
+ const source = join(probeRoot, "src"); await mkdir(source, { mode: 0o755 }); await chmod(source, 0o755);
711
+ await writeFile(join(source, "source-proof"), "AHUB_REMOTE_SOURCE", { mode: 0o644 }); await chmod(join(source, "source-proof"), 0o644);
712
+ let clean = false;
713
+ try {
714
+ const launched = await command(["run", "--detach", "--rm", "--name", name, "--label", `agenthub.project=${options.projectKey}`, "--label", `agenthub.probe=${probeId}`, ...hardening, remote.image, "sleep", "600"]);
715
+ if (launched.code !== 0) throw new RemoteTransportError();
716
+ await copy(command, source, name, new AbortController().signal);
717
+ const result = await command(execArgs(name, ["bun", "-e", 'const {networkInterfaces}=await import("node:os"); if(process.getuid?.()!==65532||Object.keys(networkInterfaces()).some(name=>name!=="lo")||await Bun.file("/work/source-proof").text()!=="AHUB_REMOTE_SOURCE")process.exit(2); await Bun.write("/tmp/ready","ok"); try{await Bun.write("/root-write-proof","no");process.exit(3)}catch{} console.log(JSON.stringify({marker:"AHUB_REMOTE_READY",arch:process.arch,platform:process.platform}));']));
718
+ const observed = result.code === 0 ? JSON.parse(result.stdout) as { marker?: string; arch?: string; platform?: string } : undefined;
719
+ architecture = observed?.arch === "x64" ? "amd64" : observed?.arch;
720
+ if (observed?.marker !== "AHUB_REMOTE_READY" || observed.platform !== "linux" || architecture !== metadata.arch) throw new RemoteTransportError();
721
+ } finally { clean = await cleanup(teardown, name); }
722
+ if (!clean) throw new RemoteTransportError();
723
+ return { available: true, platform: `linux/${architecture} (remote docker ${server.Version!.match(/^\d+\.\d+\.\d+/)![0]})` };
724
+ } catch { return { available: false, reason: "remote runner unavailable" }; }
725
+ finally { if (probeRoot) await rm(probeRoot, { recursive: true, force: true }).catch(() => {}); }
726
+ },
727
+ async isolate() { /* Fixed --network none needs no mutable host network. */ },
728
+ runArgs(receipt, labels) {
729
+ // ponytail: root and prepared dependency readiness remain reserved until 793b; no host path is used.
730
+ return ["run", "--detach", "--rm", "--name", receipt.container, ...labels, ...hardening,
731
+ "--mount", `type=volume,src=agenthub-check-deps-unprepared-${options.projectKey},dst=/work/node_modules,readonly`, "--workdir", "/work", remote.image, "sleep", "600"];
732
+ },
733
+ };
734
+ }
735
+ /** Same lifecycle, but no production checks capability until 793b proves prepared dependencies. */
736
+ export class RemoteRunner extends ContainerRunner {
737
+ constructor(options: RunnerOptions) {
738
+ super(options, remoteRunnerTransport(options));
739
+ }
740
+ override async available(signal?: AbortSignal): Promise<RunnerState> {
741
+ const transport = await super.available(signal);
742
+ return transport.available ? { ...transport, available: false, reason: "remote dependencies unprepared" } : transport;
743
+ }
744
+ }
@@ -166,6 +166,8 @@ export interface ConductorHooks {
166
166
  status(): ConductorStatusInput;
167
167
  task(id: number): Task | undefined;
168
168
  publicView(task: Task): Record<string, unknown>;
169
+ /** #812 AC5: true for a peer holding the configured planner role; it may read any task's public view. */
170
+ isPlanner?(actor: string): boolean;
169
171
  /** These callbacks must call Tasks, never Board.update. */
170
172
  assign(actor: string, id: number, peer: string, role?: TaskAssignmentRole): Promise<unknown>;
171
173
  escalate(actor: string, id: number): Promise<unknown>;
@@ -202,6 +204,9 @@ export class Conductor {
202
204
  const task = id === undefined ? undefined : this.hooks.task(id);
203
205
  // A task's current owner and reviewer read its public view without the role (#208); nobody else does.
204
206
  if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
207
+ // #812 AC5: the configured planner reads any task the conductor can, through the same public view (a PII task stays a stub). A staged backlog row stays hidden from agent task
208
+ // tools (#263: backlog is read through planner tools), and a peer that is also the conductor takes the audited conductor path above this branch. Read only: no hold or assignment.
209
+ if (task && task.state !== "backlog" && tool === "hub_task_show" && this.hooks.isPlanner?.(actor)) return publicConductorTask(task, this.hooks.publicView);
205
210
  // Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
206
211
  // worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
207
212
  // reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
@@ -1,3 +1,5 @@
1
+ import { operationalSettingKey } from "./operational-authority.ts";
2
+ import { remoteRunnerConfig } from "./check-runner.ts";
1
3
  import { ACTION_CLASSES } from "./action-class.ts";
2
4
  import { actionPolicyConfig } from "./action-policy.ts";
3
5
  import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
@@ -39,7 +41,7 @@ export interface ConfigAuthorizationInput {
39
41
  /** Captured host filename, not the requested setting or a caller-provided authority. */
40
42
  sourceFile?: string;
41
43
  tracked: boolean | undefined;
42
- authority: "ordinary" | "settings" | "terminal";
44
+ authority: "ordinary" | "settings" | "terminal" | "conductor";
43
45
  /** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
44
46
  confirmed?: boolean;
45
47
  /** Root proved the destination equals an unchanged, effective startup declaration in this origin. */
@@ -60,9 +62,14 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
60
62
  if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
61
63
  if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
62
64
  }
65
+ const remote = key === "checks.remote" || key.startsWith("checks.remote.") || key === "checks.runner" && after === "remote"
66
+ || key === "checks" && !!after && typeof after === "object" && ((after as Record<string, unknown>).runner === "remote" || (after as Record<string, unknown>).remote !== undefined);
67
+ if (remote && after !== undefined && (input.sourceFile !== "config.local.json" || input.tracked !== false)) return result(false, true, "remote checks require untracked config.local.json");
63
68
  if (same(before, after)) return result(true, false, "unchanged");
64
69
  const supplied = input.guard;
65
70
  const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
71
+ if (input.authority === "conductor" && (!guard || input.source !== "config" || guard.store !== "config" || input.structural
72
+ || !(operationalSettingKey(key) || /^roster\.[a-z][a-z0-9-]{0,31}$/.test(key)))) return result(false, true, "conductor changes require one selected operational registry setting");
66
73
  const machine = input.source === "config" && ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
67
74
  const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
68
75
  const subset = (smaller: unknown, larger: unknown) => strings(larger) && (smaller === undefined || strings(smaller) && smaller.every(item => larger.includes(item)));
@@ -88,7 +95,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
88
95
  if (input.source === "config" && (key === "pet" || key.startsWith("pet."))) return result(true, false, "console-local presentation");
89
96
  if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
90
97
  if (input.acceptedFallback && !machine) {
91
- const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
98
+ const person = input.authority === "terminal" || (input.authority === "settings" || input.authority === "conductor") && !!guard;
92
99
  return result(person && input.confirmed === true, true, "restoring an unchanged effective startup value needs this key to be explicitly confirmed");
93
100
  }
94
101
  // Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
@@ -101,7 +108,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
101
108
  return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
102
109
  }
103
110
  type TrustLayer = ConfigLayer | { name: "routing.toml" | "routing.local.toml"; raw: Record<string, unknown> };
104
- type Authority = "ordinary" | "settings" | "terminal";
111
+ type Authority = "ordinary" | "settings" | "terminal" | "conductor";
105
112
  type Check = (value: unknown, key: string) => void;
106
113
  const fail = (key: string, reason = "invalid value"): never => { throw new Error(`${key}: ${reason}`); };
107
114
  const object = (value: unknown, key: string): Record<string, unknown> => {
@@ -173,14 +180,15 @@ const schema: Record<string, Check> = {
173
180
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
174
181
  if (value === undefined) continue;
175
182
  if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
176
- else if (key === "runner") words(["container"])(value, `${k}.${key}`);
183
+ else if (key === "runner") words(["container", "remote"])(value, `${k}.${key}`);
184
+ else if (key === "remote") remoteRunnerConfig(value);
177
185
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
178
186
  } },
179
187
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
180
188
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
181
189
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
182
190
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
183
- review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool }), recovery: block({ auto_resume_after_crash: bool }),
191
+ review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
184
192
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
185
193
  };
186
194
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
@@ -206,7 +214,7 @@ export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapter
206
214
  delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
207
215
  validateRawConfig(raw, undefined, catalog);
208
216
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
209
- for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
217
+ for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", "remote", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
210
218
  for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
211
219
  }
212
220
 
@@ -231,7 +239,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
231
239
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
232
240
  const special: Record<string, readonly string[]> = {
233
241
  task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
234
- checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
242
+ checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"],
235
243
  };
236
244
  let check = schema[top];
237
245
  for (const [index, part] of key.split(".").slice(1).entries()) {
@@ -298,6 +306,7 @@ export function assertReloadTrust(cwd: string, layers: readonly TrustLayer[], pr
298
306
  if (!guard.machine && value !== undefined && same(value, guard.floor)) continue;
299
307
  // Empty machine-local declarations choose no program/path and are stripped at startup.
300
308
  if (!inherit && guard.machine && (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length)) continue;
309
+ if (authority === "conductor" && (!guard.registered || !(operationalSettingKey(key) || /^roster\.[a-z][a-z0-9-]{0,31}$/.test(key)))) fail(`${layer.name}:${key}`, "conductor changes require an operational registry setting");
301
310
  let tracked = configTracked(cwd, layer.name);
302
311
  if (!guard.machine && tracked === undefined && !configRepositoryPresent(cwd)) tracked = false;
303
312
  if (tracked !== false) fail(`${layer.name}:${key}`, tracked ? "guarded change is tracked" : "guarded source trust is unknown");
@@ -47,7 +47,7 @@ export interface ConfigEditView {
47
47
  fields: { instance: readonly string[]; tier3Instance: readonly string[] };
48
48
  }
49
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"]];
50
+ const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["review", "same_family"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
51
51
 
52
52
  export const CONFIG_EDIT_OPS = ["config_edit_get", "config_edit_preview", "config_edit_apply"] as const;
53
53
 
@@ -19,7 +19,7 @@ export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provid
19
19
  /** Every NEVER_EDITABLE key except `instances`, which splits by field. */
20
20
  export const TIER3_PREFIXES: readonly string[] = NEVER_EDITABLE.filter(key => key !== "instances");
21
21
  const TIER2_BLOCKS: Readonly<Record<string, readonly string[]>> = {
22
- review: ["adaptive", "min_reviews", "pool", "rerotate"],
22
+ review: ["adaptive", "min_reviews", "pool", "rerotate", "same_family"],
23
23
  snapshots: ["keep"],
24
24
  approvals: ["timeout_s"],
25
25
  context: ["gate"],