@staix/agent-hub 0.12.38 → 0.12.40

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 (86) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +19 -10
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/budget.md +2 -0
  6. package/docs/agent-notes/bus.md +2 -2
  7. package/docs/agent-notes/daemon.md +10 -5
  8. package/docs/agent-notes/models.md +4 -4
  9. package/docs/agent-notes/tasks.md +3 -0
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +167 -30
  13. package/docs/quickstart.md +25 -5
  14. package/docs/security.md +20 -8
  15. package/docs/smoke.md +2 -2
  16. package/docs/specs/2026-09-19-agent-hub-design.md +167 -72
  17. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  20. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  21. package/docs/verified.json +38 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +679 -193
  25. package/src/adapters/acp.ts +54 -3
  26. package/src/adapters/claude-channel.ts +35 -4
  27. package/src/adapters/codex-appserver.ts +12 -3
  28. package/src/adapters/definitions/builtin-metadata.ts +6 -4
  29. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  30. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  31. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  32. package/src/adapters/definitions/native-cli-entry-jcode.ts +90 -11
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  34. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  35. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  36. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  37. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  38. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  39. package/src/adapters/jcode.ts +165 -2
  40. package/src/adapters/local-worker.ts +21 -37
  41. package/src/adapters/pi.ts +7 -0
  42. package/src/adapters/registry.ts +1 -1
  43. package/src/cli/console-state.ts +1 -0
  44. package/src/cli/console.ts +11 -1
  45. package/src/cli/help.ts +13 -8
  46. package/src/cli/identity-audit.ts +33 -7
  47. package/src/cli/jcode-mcp.ts +1 -1
  48. package/src/cli/main.ts +138 -28
  49. package/src/cli/output.ts +5 -2
  50. package/src/cli/preview.ts +2 -2
  51. package/src/cli/status-lines.ts +1 -1
  52. package/src/cli/terminal-recovery.ts +54 -4
  53. package/src/cli/upgrade-interactive.ts +5 -0
  54. package/src/cli/upgrade-runtime.ts +13 -1
  55. package/src/cli/upgrade.ts +67 -1
  56. package/src/hub/board.ts +4 -2
  57. package/src/hub/bus.ts +8 -3
  58. package/src/hub/checks.ts +15 -5
  59. package/src/hub/conductor.ts +2 -1
  60. package/src/hub/config-apply.ts +20 -2
  61. package/src/hub/config-reload.ts +2 -8
  62. package/src/hub/control-client.ts +26 -3
  63. package/src/hub/daemon.ts +166 -67
  64. package/src/hub/delivery-superseded.ts +19 -2
  65. package/src/hub/events.ts +4 -1
  66. package/src/hub/hub-tools.ts +4 -4
  67. package/src/hub/inference.ts +4 -8
  68. package/src/hub/lifecycle.ts +3 -3
  69. package/src/hub/main-checkout.ts +36 -0
  70. package/src/hub/memory-profile-check.ts +6 -2
  71. package/src/hub/memory-profile.ts +18 -1
  72. package/src/hub/peers.ts +4 -2
  73. package/src/hub/ports.ts +1 -0
  74. package/src/hub/routing.ts +24 -14
  75. package/src/hub/settings.ts +1 -1
  76. package/src/hub/start-mode.ts +1 -1
  77. package/src/hub/task-repository.ts +136 -0
  78. package/src/hub/tasks.ts +101 -21
  79. package/src/hub/terminal-launch.ts +130 -0
  80. package/src/hub/usage.ts +44 -18
  81. package/src/models/route/config.ts +1 -1
  82. package/src/omniroute/client.ts +7 -19
  83. package/templates/AGENTS.block.md +1 -0
  84. package/templates/routing.toml +10 -44
  85. package/src/switchyard/config.ts +0 -45
  86. package/src/switchyard/sidecar.ts +0 -136
@@ -51,6 +51,8 @@ export interface PlannedProject {
51
51
  blockers: string[];
52
52
  /** Unmanaged sessions (#206): their plugin reattaches to the new daemon; no terminal is closed or relaunched and no session id is compared. */
53
53
  reconnectOnly?: string[];
54
+ /** Attached native terminals with neither a current live launcher record nor an independently verified terminal binding. */
55
+ unrecordedTerminals?: string[];
54
56
  /** #215: Codex threads with no rollout and no turn the hub recorded; they restart as a new session, nothing to lose. */
55
57
  freshStart?: string[];
56
58
  }
@@ -99,6 +101,15 @@ export interface ReattachmentMarker {
99
101
  cause: "detached after restore";
100
102
  binding: TerminalBinding;
101
103
  }
104
+ /** Trusted per-invocation attribution from the CLI identity preflight, never authority read from a receipt. */
105
+ export interface RecoveryDelegation {
106
+ peer: string;
107
+ command: "upgrade" | "restart" | "recovery resume" | "recovery abort" | "recovery dispose" | "recovery-run";
108
+ }
109
+ type RecoveryDelegationAudit = RecoveryDelegation & { at: number; operationId: string };
110
+ const MAX_RECOVERY_DELEGATIONS = 256;
111
+ const RECOVERY_DELEGATION_COMMANDS: readonly string[] = ["upgrade", "restart", "recovery resume", "recovery abort", "recovery dispose", "recovery-run"];
112
+
102
113
  export interface RecoveryOperation {
103
114
  /**
104
115
  * #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
@@ -112,6 +123,8 @@ export interface RecoveryOperation {
112
123
  phase: "pending" | "running" | "blocked" | "completed" | "cancelled";
113
124
  step: string;
114
125
  sourceRoot: string;
126
+ /** Ids-only initiating and subsequent delegated invocations; retained across coordinator replacement. */
127
+ delegatedAudits?: RecoveryDelegationAudit[];
115
128
  targetRoot?: string;
116
129
  targetDigest?: string;
117
130
  /** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
@@ -395,7 +408,7 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
395
408
  return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
396
409
  }
397
410
 
398
- export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome()): RecoveryOperation {
411
+ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation): RecoveryOperation {
399
412
  if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
400
413
  if (!plan.projects.length) throw new Error("no running projects to recover");
401
414
  const { fingerprint, ...body } = plan;
@@ -405,6 +418,7 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
405
418
  schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
406
419
  projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
407
420
  };
421
+ if (delegation) appendRecoveryDelegation(op, delegation, op.createdAt);
408
422
  writeOperation(id, op, home);
409
423
  acquireRecoveryLock(id, home);
410
424
  return op;
@@ -423,11 +437,49 @@ function abandoned(op: RecoveryOperation): boolean {
423
437
  return true;
424
438
  }
425
439
 
440
+ function validRecoveryDelegation(value: unknown): value is RecoveryDelegation {
441
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
442
+ const row = value as Record<string, unknown>;
443
+ return typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer)
444
+ && typeof row.command === "string" && RECOVERY_DELEGATION_COMMANDS.includes(row.command);
445
+ }
446
+
447
+ function validateRecoveryDelegations(op: RecoveryOperation): void {
448
+ if (op.delegatedAudits === undefined) return;
449
+ if (!Array.isArray(op.delegatedAudits) || op.delegatedAudits.length > MAX_RECOVERY_DELEGATIONS || op.delegatedAudits.some(row =>
450
+ !validRecoveryDelegation(row) || Object.keys(row).some(key => !["at", "peer", "command", "operationId"].includes(key)) ||
451
+ !Number.isFinite(row.at) || row.at < 0 || row.operationId !== op.id)) throw new Error("invalid recovery delegation audit");
452
+ }
453
+
454
+ function appendRecoveryDelegation(op: RecoveryOperation, delegation: RecoveryDelegation, at: number): void {
455
+ validateRecoveryDelegations(op);
456
+ if (!validRecoveryDelegation(delegation) || Object.keys(delegation).some(key => !["peer", "command"].includes(key)) ||
457
+ !Number.isFinite(at) || at < 0) throw new Error("invalid recovery delegation audit");
458
+ if ((op.delegatedAudits?.length ?? 0) >= MAX_RECOVERY_DELEGATIONS) throw new Error("recovery delegation audit is full; nothing was executed");
459
+ (op.delegatedAudits ??= []).push({ at, peer: delegation.peer, command: delegation.command, operationId: op.id });
460
+ }
461
+
462
+ /** Record an external resume before handing off to even an older retained coordinator. */
463
+ export function recordRecoveryDelegation(id: string, delegation: RecoveryDelegation, home = hubHome()): RecoveryOperation {
464
+ validateReceipt(id, readOperation<RecoveryOperation>(id, home));
465
+ const releaseRunner = claimRunner(id, home);
466
+ try {
467
+ const op = readOperation<RecoveryOperation>(id, home);
468
+ validateReceipt(id, op);
469
+ const at = Date.now();
470
+ appendRecoveryDelegation(op, delegation, at);
471
+ op.updatedAt = at;
472
+ writeOperation(id, op, home);
473
+ return op;
474
+ } finally { releaseRunner(); }
475
+ }
476
+
426
477
  function validateReceipt(id: string, op: RecoveryOperation, historical = false): void {
427
478
  // Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
428
479
  const options = historical ? { authority: "historical" as const } : {};
429
480
  const decode = (value: unknown) => decodeRestoreReceipt(value, options);
430
481
  if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
482
+ validateRecoveryDelegations(op);
431
483
  if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
432
484
  const { fingerprint, ...reviewed } = op.plan;
433
485
  if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
@@ -524,6 +576,10 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
524
576
  releaseRunner();
525
577
  return op;
526
578
  }
579
+ if (driver.liveTestPeer) {
580
+ appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery-run" }, driver.now());
581
+ writeOperation(id, op, home);
582
+ }
527
583
  } catch (error) { releaseRunner(); throw error; }
528
584
  let targetFeatures: readonly string[] = [];
529
585
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
@@ -778,6 +834,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
778
834
 
779
835
  /** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
780
836
  export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
837
+ validateRecoveryDelegations(op);
781
838
  const effect = (key: string, value: unknown) => {
782
839
  if (!key.startsWith("restored:")) return value === "pending" || value === "failed" ? value : "done";
783
840
  const receipt = decodeRestoreReceipt(value, { authority: "historical" });
@@ -798,6 +855,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
798
855
  ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
799
856
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
800
857
  ...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
858
+ ...(op.delegatedAudits ? { delegatedAudits: op.delegatedAudits } : {}),
801
859
  ...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.blockedReattachment ? { blockedReattachment: op.blockedReattachment } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), ...(op.freshOverride ? { freshOverride: op.freshOverride } : {}), ...(op.missingRecordedSession ? { missingRecordedSession: op.missingRecordedSession } : {}),
802
860
  ...(op.waits ? { waits: op.waits } : {}), ...(op.presentationCause ? { presentationCause: op.presentationCause } : {}), next };
803
861
  }
@@ -881,6 +939,10 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
881
939
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
882
940
  return;
883
941
  }
942
+ if (driver.liveTestPeer) {
943
+ appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery abort" }, driver.now());
944
+ writeOperation(id, op, home);
945
+ }
884
946
  if (abandoned(op)) writeOperation(id, op, home);
885
947
  const { fingerprint, ...body } = op.plan;
886
948
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
@@ -925,6 +987,10 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
925
987
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
926
988
  throw new Error(`operation is already ${op.phase}`);
927
989
  }
990
+ if (driver.liveTestPeer) {
991
+ appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery dispose" }, driver.now());
992
+ writeOperation(id, op, home);
993
+ }
928
994
  if (abandoned(op)) writeOperation(id, op, home);
929
995
  const at = driver.now();
930
996
  // Read once, as status and the runner do: every choice and refusal below is decided on it.
package/src/hub/board.ts CHANGED
@@ -45,6 +45,8 @@ export interface HistoryEntry {
45
45
  owner?: PeerId | null;
46
46
  /** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
47
47
  from?: PeerId;
48
+ /** On a decline: the owner had no capacity right now (#531). Absent on older rows and on a plain refusal, which read as lasting. */
49
+ capacity?: true;
48
50
  /** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
49
51
  profile?: string;
50
52
  /** Structured cause recorded at the operation source, never inferred from note text. */
@@ -302,7 +304,7 @@ export class Board {
302
304
 
303
305
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
304
306
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
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 {
307
+ 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" | "capacity" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
306
308
  const task = this.get(id);
307
309
  if (!task) throw new Error(`no task #${id}`);
308
310
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -318,7 +320,7 @@ export class Board {
318
320
  }
319
321
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
320
322
  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 } : {}) }];
323
+ 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.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
322
324
  this.taskWrite(() => this.db
323
325
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
324
326
  .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));
package/src/hub/bus.ts CHANGED
@@ -230,12 +230,12 @@ export class Bus {
230
230
  this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${refused}`));
231
231
  return refused;
232
232
  }
233
- this.publish(env);
234
- if (offRoster.length) this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
233
+ const published = this.publishResult(env);
234
+ if (offRoster.length && published.dropped !== "hop") this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
235
235
  // The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
236
236
  // not sent only when nobody got it.
237
237
  if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
238
- return heldBack ? hushed[0]!.reason : undefined;
238
+ return heldBack ? hushed[0]!.reason : published.dropped === "hop" ? published : undefined;
239
239
  };
240
240
  peer.onFailed = (envs, failure) => {
241
241
  // The adapter got the condensed list; what has to come back is what that list replaced.
@@ -894,6 +894,11 @@ export class Bus {
894
894
  return (this.queues.get(id) ?? []).filter((e) => e.priority === "important").length;
895
895
  }
896
896
 
897
+ /** The oldest `important` envelope still waiting in a queue, for the busy-peer notice (#572 AC1); a read, never a delivery. */
898
+ oldestQueuedImportantAt(id: PeerId): number | undefined {
899
+ return (this.queues.get(id) ?? []).reduce<number | undefined>((oldest, e) => e.priority !== "important" ? oldest : oldest === undefined ? e.ts : Math.min(oldest, e.ts), undefined);
900
+ }
901
+
897
902
  private enqueue(id: PeerId, env: Envelope, front = false): void {
898
903
  if (this.withdrawn.has(env.id)) return;
899
904
  const queue = this.queues.get(id) ?? [];
package/src/hub/checks.ts CHANGED
@@ -15,16 +15,19 @@ const TAIL_CHARS = 4000;
15
15
  const KEEP_CHARS = 64 * 1024;
16
16
  /** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
17
17
  const DRAIN_MS = 500;
18
+ /** How long a killed check may wait for its exit and close events before it settles without them (#558). */
19
+ const KILL_GRACE_MS = 1000;
18
20
 
19
21
  /** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
20
- export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>): Promise<CheckResult> {
22
+ export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: typeof spawn = spawn): Promise<CheckResult> {
21
23
  return new Promise((resolve) => {
22
24
  let out = "";
23
25
  let timedOut = false;
24
26
  let interrupted = false;
25
27
  let settled = false;
26
28
  let exited = false;
27
- const child = spawn("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
29
+ const child = spawnChild("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
30
+ let grace: ReturnType<typeof setTimeout> | undefined;
28
31
  const killGroup = () => {
29
32
  try {
30
33
  if (child.pid) process.kill(-child.pid, "SIGKILL");
@@ -32,10 +35,15 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
32
35
  // already gone
33
36
  }
34
37
  };
38
+ /** A kill that is a verdict (timeout, hub stop) settles even if the child's events never come. */
39
+ const killAndSettle = () => {
40
+ killGroup();
41
+ grace ??= setTimeout(() => done(null), KILL_GRACE_MS);
42
+ };
35
43
  const stop = () => {
36
- if (exited) return; // finished before the hub stopped: its own result stands
44
+ if (exited || settled) return; // finished before the hub stopped, or already settled: its result stands and the group may be reused
37
45
  interrupted = true;
38
- killGroup();
46
+ killAndSettle();
39
47
  };
40
48
  running?.add(stop);
41
49
  const keep = (chunk: string) => {
@@ -48,12 +56,13 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
48
56
  }
49
57
  const timer = setTimeout(() => {
50
58
  timedOut = true;
51
- killGroup();
59
+ killAndSettle();
52
60
  }, timeoutMs);
53
61
  const done = (code: number | null) => {
54
62
  if (settled) return;
55
63
  settled = true;
56
64
  clearTimeout(timer);
65
+ clearTimeout(grace);
57
66
  running?.delete(stop);
58
67
  child.stdout?.destroy();
59
68
  child.stderr?.destroy();
@@ -65,6 +74,7 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
65
74
  done(null);
66
75
  });
67
76
  child.on("exit", (code) => {
77
+ if (settled) return; // the kill grace settled it: the process group id is no longer ours to signal
68
78
  exited = true;
69
79
  clearTimeout(timer); // it finished in time; only its output may still be on the way
70
80
  killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
@@ -240,7 +240,8 @@ export class Conductor {
240
240
  if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
241
241
  const result = await this.hooks.start(start.peer);
242
242
  const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
243
- emit({ peer: start.peer }); return { ...start, ...opened };
243
+ const already = result && typeof result === "object" && (result as { already?: unknown }).already === true ? { already: true } : {};
244
+ emit({ peer: start.peer }); return { ...start, ...opened, ...already };
244
245
  }
245
246
  const peer = peerId(args.peer, true);
246
247
  if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
@@ -11,6 +11,8 @@ import { taskSweepConfig } from "./task-sweep.ts";
11
11
  import { projectionConfig } from "./text-projection.ts";
12
12
  import { validateInstances, type InstancesConfig } from "./instances.ts";
13
13
  import { peerRosterConfig } from "./roster.ts";
14
+ import { CHARACTER_IDS } from "../pet/characters.ts";
15
+ import { petPalette } from "../pet/config.ts";
14
16
 
15
17
  export type ApplyClass = "live" | "next use" | "restart";
16
18
  export interface ApplyRule { applies: ApplyClass; reason?: string }
@@ -63,6 +65,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
63
65
  if (["inference.enabled", "inference.triage", "review.adaptive"].includes(key) && before === true && after === false) tightening = true;
64
66
  if (key === "review.min_reviews" && numeric(before) && numeric(after) && after >= before) tightening = true;
65
67
  if (tightening && !input.settledHolderChange) return result(true, false, "semantic tightening");
68
+ if (input.source === "config" && (key === "pet" || key.startsWith("pet."))) return result(true, false, "console-local presentation");
66
69
  if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
67
70
  if (input.acceptedFallback && !machine) {
68
71
  const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
@@ -106,12 +109,26 @@ const block = (schema: Record<string, Check>): Check => {
106
109
  };
107
110
  const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
108
111
  const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
112
+ const petFields: Record<string, Check> = {
113
+ character: words(CHARACTER_IDS),
114
+ size: words(["line", "mini", "full"]),
115
+ glyphs: words(["auto", "ascii", "blocks"]),
116
+ blink: bool,
117
+ bell: bool,
118
+ palette: (v, k) => { if (!petPalette(v)) fail(k, "unknown choice"); },
119
+ window: bool,
120
+ };
121
+ export const validatePetConfig: Check = (v, k) => {
122
+ if (v === false) return;
123
+ block(petFields)(v, k);
124
+ };
125
+ shapes.set(validatePetConfig, { fields: petFields });
109
126
  const nativeValidators = { bool, text, strings, argv, number, words, block };
110
127
  const schema: Record<string, Check> = {
111
128
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
112
129
  watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
113
130
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
114
- instances: v => { validateInstances(v); },
131
+ instances: v => { validateInstances(v); }, pet: validatePetConfig,
115
132
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
116
133
  memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
117
134
  short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
@@ -142,6 +159,7 @@ const schema: Record<string, Check> = {
142
159
  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 }),
143
160
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
144
161
  };
162
+ export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
145
163
  export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
146
164
  const value = object(raw, "config");
147
165
  const instances = validateInstances(value.instances ?? declarations);
@@ -169,7 +187,7 @@ const restart: Record<string, string> = {
169
187
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
170
188
  text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
171
189
  };
172
- const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify"]);
190
+ const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
173
191
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
174
192
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
175
193
  export function configApply(key: string): ApplyRule {
@@ -35,7 +35,7 @@ export function configSourceTexts(cwd: string): ConfigSourceTexts {
35
35
 
36
36
  export const configFingerprint = (texts: ConfigSourceTexts): string => createHash("sha256").update(JSON.stringify(texts)).digest("hex");
37
37
 
38
- /** Validate only the hub-owned routing schema; Switchyard target/route fields remain extensible. */
38
+ /** Validate active routing; bounded legacy tables are ignored as retired settings. */
39
39
  function validateRoutingRaw(value: unknown): void {
40
40
  const bad = (key: string): never => { throw new Error(`.agenthub/routing.toml: ${key} is invalid or unknown`); };
41
41
  const table = (v: unknown, key: string): Record<string, unknown> => {
@@ -62,12 +62,6 @@ function validateRoutingRaw(value: unknown): void {
62
62
  else if (Array.isArray(v)) { if (v.length > 1024) bad(key); for (const item of v) openValue(item, key, depth + 1); }
63
63
  else for (const item of Object.values(table(v, key))) openValue(item, key, depth + 1);
64
64
  };
65
- const switchyard = (v: unknown, key: string, required: "id" | "type") => {
66
- for (const [name, item] of Object.entries(table(v, key))) {
67
- if (!name || name.length > 128 || ["__proto__", "constructor", "prototype"].includes(name)) bad(key);
68
- const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
69
- }
70
- };
71
65
  const nativeValidators = { bool, text, strings, argv: strings,
72
66
  number: (min = 1, max = Number.MAX_SAFE_INTEGER, _integer = true) => (v: unknown, k: string) => numeric(v, k, min, max),
73
67
  words: (values: readonly string[]) => (v: unknown, k: string) => choice(v, k, values),
@@ -75,7 +69,7 @@ function validateRoutingRaw(value: unknown): void {
75
69
  };
76
70
  closed(value, "routing", {
77
71
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.routingSchema?.(nativeValidators) ?? {})),
78
- targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
72
+ targets: openValue, routes: openValue,
79
73
  // parseRouting delegates these tables to the existing parseHubRoutes validator.
80
74
  hub_routes: (v, k) => { table(v, k); },
81
75
  stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { projectContext } from "./project.ts";
4
4
 
5
5
  export function stateDirFor(cwd: string): string {
@@ -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 = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool"] as const;
11
+ export const DAEMON_FEATURES = ["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
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
 
@@ -18,6 +18,10 @@ export interface Hello {
18
18
  peer?: string;
19
19
  /** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
20
20
  channel?: boolean;
21
+ /** Managed Claude launch identity; absent for plain or pre-daemon sessions. */
22
+ launchId?: string;
23
+ /** Reclaim after replacement, never an intentional initial takeover. */
24
+ standby?: boolean;
21
25
  projectId?: string;
22
26
  instanceId?: string;
23
27
  projectRoot?: string;
@@ -56,9 +60,15 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
56
60
  }
57
61
  }
58
62
 
63
+ const instancePins = new Map<string, { instanceId: string }>();
64
+
59
65
  /** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
60
66
  export class ControlClient {
61
67
  features: readonly string[] = [];
68
+ private welcomeInstanceId?: string;
69
+ /** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
70
+ get instanceId(): string | undefined { return this.welcomeInstanceId; }
71
+ get isOpen(): boolean { return this.ws.readyState === WebSocket.OPEN; }
62
72
  private nextRid = 1;
63
73
  private readonly pending = new Map<number, (msg: any) => void>();
64
74
  onPush: (msg: any) => void = () => {};
@@ -67,11 +77,23 @@ export class ControlClient {
67
77
 
68
78
  private constructor(private readonly ws: WebSocket) {}
69
79
 
80
+ /** Scope all helper-module connections in one CLI invocation to its authenticated audit consumer. */
81
+ static pinInstance(stateDir: string, instanceId: string): () => void {
82
+ const key = resolve(stateDir), pin = { instanceId };
83
+ if (!instanceId || instancePins.has(key)) throw new Error("control instance is already pinned or unavailable");
84
+ instancePins.set(key, pin);
85
+ return () => { if (instancePins.get(key) === pin) instancePins.delete(key); };
86
+ }
87
+
70
88
  /** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
71
89
  static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol = PROTOCOL): Promise<ControlClient> {
72
90
  if (!RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
73
91
  const control = readControl(stateDir);
74
92
  if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
93
+ const pin = instancePins.get(resolve(stateDir));
94
+ if (pin && (control.instanceId !== pin.instanceId || hello.instanceId !== undefined && hello.instanceId !== pin.instanceId)) {
95
+ return Promise.reject(new Error("delegated audit consumer changed; refusing a connection to its replacement"));
96
+ }
75
97
  if (control.protocol !== undefined && control.protocol !== protocol) {
76
98
  return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${protocol}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
77
99
  }
@@ -80,7 +102,7 @@ export class ControlClient {
80
102
  (hello.projectRoot && hello.projectRoot !== control.cwd)) {
81
103
  return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
82
104
  }
83
- const expected = { projectId: hello.projectId ?? control.projectId, instanceId: hello.instanceId ?? control.instanceId,
105
+ const expected = { projectId: hello.projectId ?? control.projectId, instanceId: pin?.instanceId ?? hello.instanceId ?? control.instanceId,
84
106
  projectRoot: hello.projectRoot ?? control.cwd };
85
107
  return new Promise((resolve, reject) => {
86
108
  const ws = new WebSocket(control.url);
@@ -113,6 +135,7 @@ export class ControlClient {
113
135
  return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
114
136
  }
115
137
  client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
138
+ client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
116
139
  clearTimeout(timer);
117
140
  resolve(client);
118
141
  });