@staix/agent-hub 0.12.36 → 0.12.38

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -11
  3. package/docs/agent-notes/adapters.md +19 -6
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/tasks.md +8 -4
  8. package/docs/agent-notes/tests.md +2 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +57 -10
  11. package/docs/quickstart.md +15 -7
  12. package/docs/sdlc.md +29 -3
  13. package/docs/security.md +10 -2
  14. package/docs/smoke.md +38 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
  16. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  17. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  18. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  19. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  20. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  21. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  22. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  23. package/docs/verified.json +27 -15
  24. package/package.json +2 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +5627 -3670
  27. package/src/adapters/acp.ts +3 -2
  28. package/src/adapters/agy/definition.ts +61 -0
  29. package/src/adapters/agy/entry.ts +76 -0
  30. package/src/adapters/agy/hooks.ts +81 -0
  31. package/src/adapters/claude-channel.ts +1 -0
  32. package/src/adapters/claude-print.ts +18 -156
  33. package/src/adapters/codex-review-isolation.ts +290 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +9 -6
  35. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
  38. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  39. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  40. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  41. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  42. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  43. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  44. package/src/adapters/definitions/native-factories.ts +17 -0
  45. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  46. package/src/adapters/definitions/qwen.ts +1 -1
  47. package/src/adapters/definitions/reviewer-variants.ts +33 -0
  48. package/src/adapters/jcode.ts +20 -5
  49. package/src/adapters/kimi-review.ts +161 -0
  50. package/src/adapters/registry-types.ts +36 -4
  51. package/src/adapters/registry.ts +28 -7
  52. package/src/adapters/reviewer-mcp.ts +85 -0
  53. package/src/adapters/reviewer-print.ts +382 -0
  54. package/src/cli/agy-mcp.ts +370 -0
  55. package/src/cli/console-state.ts +6 -0
  56. package/src/cli/console.ts +2 -0
  57. package/src/cli/facts-hook.ts +15 -2
  58. package/src/cli/help.ts +2 -0
  59. package/src/cli/identity.ts +1 -1
  60. package/src/cli/init.ts +7 -0
  61. package/src/cli/launch.ts +3 -1
  62. package/src/cli/main.ts +20 -0
  63. package/src/cli/memory.ts +13 -12
  64. package/src/cli/output.ts +5 -0
  65. package/src/hub/board.ts +34 -6
  66. package/src/hub/bus.ts +17 -6
  67. package/src/hub/config-apply.ts +3 -2
  68. package/src/hub/control-client.ts +7 -1
  69. package/src/hub/daemon.ts +266 -46
  70. package/src/hub/envelope.ts +1 -1
  71. package/src/hub/host-executable.ts +1 -1
  72. package/src/hub/hub-tools.ts +5 -0
  73. package/src/hub/instances.ts +13 -3
  74. package/src/hub/memory-profile.ts +67 -11
  75. package/src/hub/native-hook-launch.ts +37 -0
  76. package/src/hub/peer-catalog.ts +2 -3
  77. package/src/hub/planning.ts +2 -0
  78. package/src/hub/release-admission.ts +64 -0
  79. package/src/hub/reviewer-pool-config.ts +11 -0
  80. package/src/hub/reviewer-pool.ts +76 -0
  81. package/src/hub/routing.ts +45 -3
  82. package/src/hub/sdlc-release.ts +113 -0
  83. package/src/hub/tasks.ts +261 -36
  84. package/src/ui/index.html +6 -1
  85. package/templates/AGENTS.block.md +1 -1
package/src/cli/output.ts CHANGED
@@ -9,6 +9,7 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
9
9
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
10
10
  export interface StatusOutput {
11
11
  pendingRestart?: readonly { key: string; reason: string }[];
12
+ retainedConfig?: readonly { key: string; reason: string }[];
12
13
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
13
14
  pendingTaskOffers?: readonly { task: number; peer: string }[];
14
15
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
@@ -191,6 +192,10 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
191
192
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
192
193
  if (data.cwd) out.push(...lines(data.cwd, columns));
193
194
  for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
195
+ if (data.retainedConfig?.length) {
196
+ out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
197
+ for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
198
+ }
194
199
  for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
195
200
  for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
196
201
  if (data.memoryProfile) {
package/src/hub/board.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ReviewerPoolReason } from "./reviewer-pool.ts";
1
2
  import type { GitHubSource } from "./github-import.ts";
2
3
  import { Database } from "bun:sqlite";
3
4
  import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
@@ -56,8 +57,20 @@ export interface HistoryEntry {
56
57
  reviewCost?: ReviewCost;
57
58
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
58
59
  reviewContext?: "fresh" | "continuing";
60
+ reviewerFamily?: string;
61
+ reviewerReason?: ReviewerPoolReason;
62
+ reviewBlocked?: true;
63
+ reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
59
64
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
60
65
  }
66
+ export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
67
+ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
68
+ const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
69
+ if (extra.reviewerFamily !== undefined && !id(extra.reviewerFamily) || extra.reviewerReason !== undefined && !["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(extra.reviewerReason) || extra.reviewBlocked !== undefined && extra.reviewBlocked !== true) throw new Error("invalid reviewer metadata");
70
+ const failure = extra.reviewerFailure;
71
+ if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
72
+ return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
73
+ }
61
74
  export interface Task extends PlanningFields {
62
75
  offer?: TaskOffer;
63
76
  cancelled?: TaskCancellation;
@@ -209,6 +222,7 @@ export class Board {
209
222
  release_order INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'planning', planned_start INTEGER, cut INTEGER, ship INTEGER,
210
223
  activated_at INTEGER, frozen_at INTEGER, shipped_at INTEGER, created INTEGER NOT NULL, updated INTEGER NOT NULL,
211
224
  revision INTEGER NOT NULL DEFAULT 0)`);
225
+ if (!(this.db.query("PRAGMA table_info(releases)").all() as { name: string }[]).some(row => row.name === "train")) this.db.run("ALTER TABLE releases ADD COLUMN train TEXT");
212
226
 
213
227
  }
214
228
 
@@ -288,7 +302,7 @@ export class Board {
288
302
 
289
303
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
290
304
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
291
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext"> = {}, expectedFlowRevision?: number): Task {
305
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
292
306
  const task = this.get(id);
293
307
  if (!task) throw new Error(`no task #${id}`);
294
308
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -303,7 +317,8 @@ export class Board {
303
317
  if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
304
318
  }
305
319
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
306
- const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
320
+ const reviewMetadata = reviewerHistory(extra);
321
+ const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
307
322
  this.taskWrite(() => this.db
308
323
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
309
324
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
@@ -402,20 +417,33 @@ export class Board {
402
417
  releases(): Release[] {
403
418
  return (this.db.query("SELECT * FROM releases ORDER BY release_order,id").all() as Record<string, unknown>[]).map(parseRelease);
404
419
  }
405
- updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship">>, revision: number): Release {
420
+ updateRelease(id: number, patch: Partial<Pick<Release, "state" | "plannedStart" | "cut" | "ship" | "train">>, revision: number): Release {
406
421
  const release = this.release(id);
407
422
  if (!release || release.revision !== revision) throw new Error("release revision changed");
408
423
  const next = { ...release, ...patch }, now = Date.now();
409
424
  const moves: Record<ReleaseState, ReleaseState[]> = { planning: ["active", "dropped"], active: ["frozen", "dropped"], frozen: ["shipped", "dropped"], shipped: [], dropped: [] };
410
425
  if (next.state !== release.state && !moves[release.state].includes(next.state)) throw new Error("invalid release transition");
426
+ if (patch.train && next.state !== release.state && ["frozen", "shipped"].includes(next.state)) {
427
+ const event = next.train?.history.at(-1);
428
+ if (event?.event === (next.state === "frozen" ? "cut" : "shipped")) event.at = now;
429
+ }
411
430
  const dates = [next.plannedStart, next.cut, next.ship].filter((date): date is number => date !== null);
412
431
  if (dates.some(date => !Number.isSafeInteger(date) || date <= 0 || date > 8_640_000_000_000_000) || dates.some((date, i) => i > 0 && dates[i-1]! > date)) throw new Error("invalid release planned dates");
413
- const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,revision=revision+1 WHERE id=? AND revision=?")
414
- .run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, id, revision);
432
+ const result = this.db.query("UPDATE releases SET state=?,planned_start=?,cut=?,ship=?,activated_at=?,frozen_at=?,shipped_at=?,updated=?,train=?,revision=revision+1 WHERE id=? AND revision=?")
433
+ .run(next.state, next.plannedStart, next.cut, next.ship, release.activatedAt ?? (next.state === "active" ? now : null), release.frozenAt ?? (next.state === "frozen" ? now : null), release.shippedAt ?? (next.state === "shipped" ? now : null), now, next.train === undefined ? null : JSON.stringify(next.train), id, revision);
415
434
  if (result.changes !== 1) throw new Error("release revision changed");
416
435
  return this.release(id)!;
417
436
  }
418
437
 
438
+ /** Cross-connection scope changes cannot race the final freeze write. */
439
+ freezeRelease(id: number, revision: number, scope: Task[], train: NonNullable<Release["train"]>): Release {
440
+ return this.db.transaction(() => {
441
+ const release = this.release(id);
442
+ if (!release || release.revision !== revision || release.state !== "active" || this.releases().some(row => row.id !== id && ["active", "frozen"].includes(row.state)) || JSON.stringify(this.list().filter(task => task.release === id)) !== JSON.stringify(scope)) throw new Error("release scope changed before freeze");
443
+ return this.updateRelease(id, { state: "frozen", train }, revision);
444
+ })();
445
+ }
446
+
419
447
  recordOutcome(peer: PeerId, cls: TaskClass, ok: boolean, at = Date.now()): void {
420
448
  this.db.query("INSERT INTO outcomes (peer, class, ok, at) VALUES (?, ?, ?, ?)").run(peer, cls, ok ? 1 : 0, at);
421
449
  this.db.query("DELETE FROM outcomes WHERE at < ?").run(at - OUTCOMES_KEPT_MS); // nothing older is read
@@ -474,7 +502,7 @@ function parse(row: Record<string, unknown>): Task {
474
502
  }
475
503
 
476
504
  function parseRelease(row: Record<string, unknown>): Release {
477
- return { id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
505
+ return { ...(row.train == null ? {} : { train: JSON.parse(String(row.train)) }), id: Number(row.id), name: String(row.name), goal: String(row.goal), order: Number(row.release_order), state: row.state as ReleaseState,
478
506
  plannedStart: row.planned_start as number | null, cut: row.cut as number | null, ship: row.ship as number | null,
479
507
  activatedAt: row.activated_at as number | null, frozenAt: row.frozen_at as number | null, shippedAt: row.shipped_at as number | null,
480
508
  created: Number(row.created), updated: Number(row.updated), revision: Number(row.revision) };
package/src/hub/bus.ts CHANGED
@@ -7,6 +7,11 @@ import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
7
 
8
8
  export type { DeliveryReceipt } from "./peers.ts";
9
9
 
10
+ export interface PublishResult {
11
+ targets: PeerId[];
12
+ dropped?: "hop" | "fyi";
13
+ }
14
+
10
15
  export type BusEvent =
11
16
  | { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
12
17
  | { t: "overflow"; env: Envelope; peer: PeerId }
@@ -294,6 +299,7 @@ export class Bus {
294
299
  // Persist each deferred -> queued transition while deliveries are still fenced. A failed checkpoint
295
300
  // leaves the hold and the original pending head intact; a crash after it resumes from the journal.
296
301
  this.flushDeferredHub();
302
+ this.deferHub = false;
297
303
  this.recoveryHeld = false;
298
304
  this.onQueues?.();
299
305
  for (const id of this.peers.keys()) void this.drain(id);
@@ -698,8 +704,13 @@ export class Bus {
698
704
 
699
705
  /** Returns the peers the envelope was accepted for, including durable deferred notices. */
700
706
  publish(env: Envelope): PeerId[] {
707
+ return this.publishResult(env).targets;
708
+ }
709
+
710
+ /** Publication outcome distinguishes a policy drop from an empty audience. */
711
+ publishResult(env: Envelope): PublishResult {
701
712
  if (this.storageError) throw new Error("delivery journal unavailable");
702
- if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return [];
713
+ if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return { targets: [] };
703
714
  if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
704
715
  if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
705
716
  const targets = this.audience(env);
@@ -707,21 +718,21 @@ export class Bus {
707
718
  this.deferredHub.set(copy.id, copy);
708
719
  try { this.persist(); }
709
720
  catch (error) { this.deferredHub.delete(copy.id); throw error; }
710
- return targets;
721
+ return { targets };
711
722
  }
712
723
  return this.publishNow(env);
713
724
  }
714
725
 
715
- private publishNow(env: Envelope): PeerId[] {
726
+ private publishNow(env: Envelope): PublishResult {
716
727
  if (this.storageError) throw new Error("delivery journal unavailable");
717
- if (this.seen.has(env.id)) return [];
728
+ if (this.seen.has(env.id)) return { targets: [] };
718
729
  this.seen.set(env.id, env);
719
730
  if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
720
731
 
721
732
  // fyi is for the record (console now, task board from M4), never worth an agent's turn.
722
733
  const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
723
734
  this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
724
- if (dropped) { this.persist(); return []; }
735
+ if (dropped) { this.persist(); return { targets: [], dropped }; }
725
736
 
726
737
  const hushed = this.hushed(env);
727
738
  if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
@@ -756,7 +767,7 @@ export class Bus {
756
767
  }
757
768
  const offRoster = targets.filter(id => !this.isRostered(id));
758
769
  if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
759
- return targets;
770
+ return { targets };
760
771
  }
761
772
 
762
773
  isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
@@ -1,6 +1,7 @@
1
+ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
1
2
  import type { HubConfig } from "./daemon.ts";
2
3
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
3
- import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
4
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
4
5
  import { CLASSES } from "./board.ts";
5
6
  import { permissionDefaults } from "./permission-mode.ts";
6
7
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
@@ -138,7 +139,7 @@ const schema: Record<string, Check> = {
138
139
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
139
140
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
140
141
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
141
- review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
142
+ 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 }),
142
143
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
143
144
  };
144
145
  export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
8
8
 
9
9
  /** 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. */
10
10
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances"] as const;
11
+ export const DAEMON_FEATURES = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool"] as const;
12
12
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
13
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
14
 
@@ -32,6 +32,8 @@ export interface ControlDescriptor {
32
32
  protocol?: number;
33
33
  pid?: number;
34
34
  features?: readonly string[];
35
+ /** The daemon's accepted native start modes, used by launchers when the file value is retained. */
36
+ startModes?: Readonly<Record<string, "tui" | "headless">>;
35
37
  /** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
36
38
  pidSignature?: string;
37
39
  }
@@ -42,8 +44,12 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
42
44
  const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
43
45
  const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
44
46
  if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
47
+ const startModes = status.startModes && typeof status.startModes === "object" && !Array.isArray(status.startModes)
48
+ ? Object.fromEntries(Object.entries(status.startModes).filter((entry): entry is [string, "tui" | "headless"] => entry[1] === "tui" || entry[1] === "headless"))
49
+ : undefined;
45
50
  return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
46
51
  instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
52
+ ...(startModes ? { startModes } : {}),
47
53
  ...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
48
54
  } catch {
49
55
  return undefined;