@staix/agent-hub 0.12.16 → 0.12.18

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +2 -0
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/events.md +37 -3
  8. package/docs/operations.md +149 -10
  9. package/docs/quickstart.md +3 -3
  10. package/docs/security.md +28 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +176 -1
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +26 -17
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +17 -5
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +3 -2
  21. package/src/adapters/local-worker.ts +16 -12
  22. package/src/cli/console-state.ts +279 -0
  23. package/src/cli/console.ts +224 -0
  24. package/src/cli/facts-hook.ts +8 -1
  25. package/src/cli/identity-audit.ts +66 -0
  26. package/src/cli/identity.ts +54 -0
  27. package/src/cli/launch.ts +15 -2
  28. package/src/cli/main.ts +75 -32
  29. package/src/cli/tail-render.ts +17 -0
  30. package/src/cli/upgrade-runtime.ts +1 -1
  31. package/src/hub/attribution.ts +20 -0
  32. package/src/hub/board.ts +6 -2
  33. package/src/hub/bus.ts +88 -10
  34. package/src/hub/child-process.ts +11 -0
  35. package/src/hub/conductor.ts +196 -0
  36. package/src/hub/control-client.ts +3 -3
  37. package/src/hub/daemon.ts +388 -51
  38. package/src/hub/envelope.ts +1 -1
  39. package/src/hub/events.ts +10 -4
  40. package/src/hub/hub-tools.ts +13 -0
  41. package/src/hub/report.ts +162 -4
  42. package/src/hub/supervision.ts +152 -0
  43. package/src/hub/tasks.ts +30 -15
  44. package/src/hub/usage.ts +37 -2
  45. package/src/pi/launch.ts +2 -1
package/src/hub/bus.ts CHANGED
@@ -17,6 +17,8 @@ export type BusEvent =
17
17
  | { t: "state"; peer: PeerId; state: PeerState };
18
18
 
19
19
  export interface BusOptions {
20
+ /** Observe original envelopes immediately before a transport starts its turn. */
21
+ onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
20
22
  retryMs: number;
21
23
  /** A queue this long is delivered without waiting for the batch window. */
22
24
  batchMax: number;
@@ -82,6 +84,8 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
82
84
 
83
85
  const SEEN_CAP = 2048;
84
86
  const MAX_ATTEMPTS = 3;
87
+ // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
88
+ // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
85
89
  const DIGEST_MAX = 10;
86
90
  const NOTES_KEPT = 10;
87
91
 
@@ -100,6 +104,8 @@ export class Bus {
100
104
  private readonly manualPaused = new Set<PeerId>();
101
105
  private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
102
106
  private readonly draining = new Set<PeerId>();
107
+ /** Still pending while an asynchronous condensation runs; never includes native dispatches. */
108
+ private readonly preparing = new Map<PeerId, Envelope[]>();
103
109
  private readonly seen = new Map<string, Envelope>();
104
110
  private readonly taps = new Set<(e: BusEvent) => void>();
105
111
  private readonly withdrawn = new Set<string>();
@@ -119,6 +125,12 @@ export class Bus {
119
125
  * and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
120
126
  */
121
127
  onQueues?: () => void;
128
+ /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
129
+ onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
130
+ /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
131
+ onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
132
+ /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
133
+ onDeliveryFailed?: (peer: PeerId) => void;
122
134
  private recoveryHeld = false;
123
135
  private steering = 0;
124
136
  private condensing = 0;
@@ -127,6 +139,7 @@ export class Bus {
127
139
 
128
140
  constructor(opts: Partial<BusOptions> = {}) {
129
141
  this.opts = { ...DEFAULT_BUS, ...opts };
142
+ this.onDeliver = opts.onDeliver;
130
143
  this.journal = opts.journal;
131
144
  if (this.journal) {
132
145
  const state = this.journal.snapshot();
@@ -389,7 +402,9 @@ export class Bus {
389
402
  const id = receipt.id;
390
403
  const before = this.journal.get(id);
391
404
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
405
+ if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
392
406
  this.journal.transition(id, state, receipt.reason);
407
+ if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
393
408
  if (state === "failed") {
394
409
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
395
410
  }
@@ -525,7 +540,8 @@ export class Bus {
525
540
  this.activeDeliveries.set(deliveryId, id);
526
541
  if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
527
542
  const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
528
- steerResult.then(() => {}).catch(() => {
543
+ steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
544
+ this.deliveryFailed(id);
529
545
  if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
530
546
  this.activeDeliveries.delete(deliveryId);
531
547
  if (!this.journal) this.enqueue(id, env, true);
@@ -570,8 +586,7 @@ export class Bus {
570
586
  * dropped if the refused steer tries to queue it later.
571
587
  */
572
588
  withdraw(envelopeId: string): boolean {
573
- this.withdrawn.add(envelopeId);
574
- if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
589
+ this.markWithdrawn(envelopeId);
575
590
  let removed = false;
576
591
  for (const queue of this.queues.values()) {
577
592
  const i = queue.findIndex((e) => e.id === envelopeId);
@@ -582,6 +597,49 @@ export class Bus {
582
597
  return removed;
583
598
  }
584
599
 
600
+ /** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
601
+ revokeSupervision(peer: PeerId): number {
602
+ const queue = this.queues.get(peer);
603
+ if (!queue && !this.preparing.has(peer)) return 0;
604
+ let removed = 0;
605
+ const discarded = new Set<string>();
606
+ for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
607
+ const env = queue![i]!;
608
+ if (env.from !== HUB || !env.refs?.supervision) continue;
609
+ queue!.splice(i, 1);
610
+ removed++;
611
+ discarded.add(env.id);
612
+ this.markWithdrawn(env.id);
613
+ this.discardQueued(peer, env, "supervision role or feed revoked");
614
+ }
615
+ for (const env of this.preparing.get(peer) ?? []) {
616
+ if (env.from !== HUB || !env.refs?.supervision || discarded.has(env.id)) continue;
617
+ this.markWithdrawn(env.id);
618
+ this.discardQueued(peer, env, "supervision role or feed revoked during preparation");
619
+ removed++;
620
+ }
621
+ if (removed) { this.persist(); this.onQueues?.(); }
622
+ return removed;
623
+ }
624
+
625
+ private discardQueued(peer: PeerId, env: Envelope, reason: string): void {
626
+ if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
627
+ this.emit({ t: "stale", env, peer, reason });
628
+ }
629
+
630
+ private delivered(peer: PeerId, originals: Envelope[]): void {
631
+ try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
632
+ }
633
+
634
+ private deliveryFailed(peer: PeerId): void {
635
+ try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
636
+ }
637
+
638
+ private markWithdrawn(id: string): void {
639
+ this.withdrawn.add(id);
640
+ if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
641
+ }
642
+
585
643
  queued(id: PeerId): number {
586
644
  return this.queues.get(id)?.length ?? 0;
587
645
  }
@@ -599,8 +657,23 @@ export class Bus {
599
657
  if (this.withdrawn.has(env.id)) return;
600
658
  const queue = this.queues.get(id) ?? [];
601
659
  this.queues.set(id, queue);
602
- if (front) queue.unshift(env);
603
- else queue.push(env);
660
+ const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
661
+ const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
662
+ const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
663
+ let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
664
+ if (replace >= 0) {
665
+ const old = queue[replace]!;
666
+ // Keep the first milestone's deadline, so continuous updates cannot create another batching window.
667
+ queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
668
+ queue[replace] = queuedEnv;
669
+ this.markWithdrawn(old.id);
670
+ this.discardQueued(id, old, "superseded supervision milestone");
671
+ } else if (front) queue.unshift(queuedEnv);
672
+ else queue.push(queuedEnv);
673
+ if (preparing && !this.withdrawn.has(preparing.id)) {
674
+ this.markWithdrawn(preparing.id);
675
+ this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
676
+ }
604
677
  if (queue.length > this.opts.queueCap) {
605
678
  const victim = queue.findIndex((e) => e.priority !== "important");
606
679
  const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
@@ -614,7 +687,8 @@ export class Bus {
614
687
 
615
688
  /** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
616
689
  private wait(queue: Envelope[]): number {
617
- if (queue.length >= this.opts.batchMax || queue.some((e) => e.priority === "important")) return 0;
690
+ const supervisionOnly = queue.every((e) => e.from === HUB && e.refs?.supervision && e.priority === "status");
691
+ if ((!supervisionOnly && queue.length >= this.opts.batchMax) || queue.some((e) => e.priority === "important")) return 0;
618
692
  return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
619
693
  }
620
694
 
@@ -656,6 +730,7 @@ export class Bus {
656
730
  // may checkpoint during that wait; it must not checkpoint the batch out of existence.
657
731
  if (!this.journal) this.prefaces.delete(id);
658
732
  const batch = this.take(id, queue, !this.journal);
733
+ this.preparing.set(id, batch);
659
734
  const delivery = preface ? [preface, ...batch] : batch;
660
735
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
661
736
  this.condensing += mayCondense ? 1 : 0;
@@ -679,11 +754,13 @@ export class Bus {
679
754
  for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
680
755
  while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
681
756
  this.lastDelivery.set(id, { out, originals: delivery });
757
+ this.preparing.delete(id); // from here on the transport/journal owns settlement
682
758
  const deliveryId = crypto.randomUUID();
683
759
  if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
684
760
  try {
761
+ try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
685
762
  if (this.journal) await peer.deliver(out, deliveryId);
686
- else await peer.deliver(out);
763
+ else { await peer.deliver(out); this.delivered(id, delivery); }
687
764
  } catch {
688
765
  if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
689
766
  else this.failed(id, delivery);
@@ -691,11 +768,11 @@ export class Bus {
691
768
  }
692
769
  }
693
770
  } catch { this.storageError = "delivery journal unavailable"; }
694
- finally { this.draining.delete(id); this.onQueues?.(); }
771
+ finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
695
772
  }
696
773
 
697
774
  private isRelevant(id: PeerId, env: Envelope): boolean {
698
- return this.opts.relevant?.(id, env) ?? true;
775
+ return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
699
776
  }
700
777
 
701
778
  /**
@@ -704,7 +781,7 @@ export class Bus {
704
781
  * this recipient's copy goes: the other queues keep theirs, and no other delivery record changes.
705
782
  */
706
783
  private dropIrrelevant(id: PeerId, queue: Envelope[]): boolean {
707
- if (!this.opts.relevant) return false;
784
+ if (!this.opts.relevant && !this.withdrawn.size) return false;
708
785
  let dropped = false;
709
786
  for (let i = queue.length - 1; i >= 0; i--) {
710
787
  const env = queue[i]!;
@@ -757,6 +834,7 @@ export class Bus {
757
834
  }
758
835
 
759
836
  private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
837
+ if (!this.journal) this.deliveryFailed(id);
760
838
  this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
761
839
  if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
762
840
  this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
11
11
  return env;
12
12
  }
13
13
 
14
+ /** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
15
+ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
16
+ const env = childEnv(source);
17
+ delete env.CLAUDECODE;
18
+ delete env.CLAUDE_CODE_SESSION_ID;
19
+ delete env.CODEX_THREAD_ID;
20
+ delete env.AGENTHUB_PEER_ID;
21
+ env.AGENTHUB_PEER_ID = peer;
22
+ return env;
23
+ }
24
+
14
25
  /** One process: its identity is the pid with its start time (`lstart`, which exec keeps); group and command are evidence. */
15
26
  export interface ProcRow {
16
27
  pid: number;
@@ -0,0 +1,196 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { Task } from "./board.ts";
3
+ import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
+ import type { HubEvent } from "./events.ts";
5
+ import type { SupervisionFeed } from "./supervision.ts";
6
+ import type { Budget } from "./budget.ts";
7
+
8
+ /** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
9
+ export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
10
+ return event => { record(event); if (event.type === "stuck") feed.milestone(event.task, "stuck", { reason: event.category }); };
11
+ }
12
+
13
+ export function publicPeerBudget(status: ReturnType<Budget["status"]>, nameable: (text: string) => boolean): Record<string, unknown> {
14
+ return Object.fromEntries(Object.entries(status).map(([peer, value]) => [peer, {
15
+ windows: value.windows.map(window => ({ id: window.id, used: window.used, at: window.at, stale: window.stale,
16
+ ...(window.resetsAt === undefined ? {} : { resetsAt: window.resetsAt }),
17
+ ...(window.windowMins === undefined ? {} : { windowMins: window.windowMins }),
18
+ source: nameable(window.source) ? window.source : "[quota source withheld]",
19
+ })),
20
+ ...(value.paused ? { paused: { since: value.paused.since, resetsAt: value.paused.resetsAt, reason: "quota" } } : {}),
21
+ }]));
22
+ }
23
+
24
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
25
+ const peerId = (value: unknown): string => {
26
+ if (typeof value !== "string" || /\s/.test(value) || !PEER.test(value) || ["user", "hub", "digest"].includes(value)) throw new Error("peer must be a valid agent peer id");
27
+ return value;
28
+ };
29
+
30
+ /** Validate before listening; missing configuration grants nobody the conductor role. */
31
+ export function conductorPeer(roles: unknown): string | null {
32
+ if (roles === undefined) return null;
33
+ if (!roles || typeof roles !== "object" || Array.isArray(roles)) throw new Error("roles must be an object of peer role lists");
34
+ let conductor: string | null = null;
35
+ for (const [peer, list] of Object.entries(roles)) {
36
+ peerId(peer);
37
+ if (!Array.isArray(list) || list.some((r) => typeof r !== "string")) throw new Error(`roles.${peer} must be a list of role names`);
38
+ if (!list.includes("conductor")) continue;
39
+ if (conductor !== null) throw new Error("roles may name at most one conductor peer");
40
+ conductor = peer;
41
+ }
42
+ return conductor;
43
+ }
44
+
45
+ /** The role check always comes first; default-allow capabilities cannot grant a role. */
46
+ export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false): void {
47
+ if (conductorPeer(roles) !== peer) throw new Error("this operation requires the explicit conductor role");
48
+ if (assign && Object.hasOwn(capabilities, peer)) {
49
+ const caps = capabilities[peer];
50
+ if (!Array.isArray(caps) || !caps.includes("assign")) throw new Error(`${peer} requires assign capability for this operation`);
51
+ }
52
+ }
53
+
54
+ export interface ConductorHold { peer: string; actor: string; since: number }
55
+
56
+ /** Actor ownership survives restart. No titles, plans, summaries or memory writes. */
57
+ export class ConductorHolds {
58
+ private readonly db: Database;
59
+ constructor(dbPath: string, private readonly now: () => number = Date.now) {
60
+ this.db = new Database(dbPath, { create: true });
61
+ this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
62
+ this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
63
+ }
64
+ get(peer: string): ConductorHold | null {
65
+ return this.db.query("SELECT peer, actor, since FROM conductor_holds WHERE peer = ?").get(peer) as ConductorHold | null;
66
+ }
67
+ has(peer: string): boolean { return this.get(peer) !== null; }
68
+ list(): ConductorHold[] { return this.db.query("SELECT peer, actor, since FROM conductor_holds ORDER BY peer").all() as ConductorHold[]; }
69
+ readRound(peer: string): string | undefined { return (this.db.query("SELECT signature FROM supervision_rounds WHERE peer = ?").get(peer) as { signature: string } | null)?.signature; }
70
+ writeRound(peer: string, signature: string): void { this.db.query("INSERT INTO supervision_rounds (peer, signature) VALUES (?, ?) ON CONFLICT(peer) DO UPDATE SET signature = excluded.signature").run(peer, signature); }
71
+ hold(peer: string, actor: string): ConductorHold {
72
+ peerId(peer); peerId(actor);
73
+ // INSERT OR IGNORE cannot take ownership of an earlier conductor's hold.
74
+ this.db.query("INSERT OR IGNORE INTO conductor_holds (peer, actor, since) VALUES (?, ?, ?)").run(peer, actor, this.now());
75
+ const hold = this.get(peer)!;
76
+ if (hold.actor !== actor) throw new Error(`peer ${peer} is held by another conductor`);
77
+ return hold;
78
+ }
79
+ release(peer: string, actor: string): ConductorHold {
80
+ peerId(peer); peerId(actor);
81
+ const hold = this.get(peer);
82
+ if (!hold || hold.actor !== actor) throw new Error("you may release only a conductor hold you placed");
83
+ this.db.query("DELETE FROM conductor_holds WHERE peer = ? AND actor = ?").run(peer, actor);
84
+ return hold;
85
+ }
86
+ /** The daemon must authenticate a human console and check budget/local constraints first. */
87
+ releaseByOperator(peer: string): ConductorHold | null {
88
+ peerId(peer);
89
+ const hold = this.get(peer);
90
+ if (hold) this.db.query("DELETE FROM conductor_holds WHERE peer = ?").run(peer);
91
+ return hold;
92
+ }
93
+ close(): void { this.db.close(); }
94
+ }
95
+
96
+ export type ConductorStart = { peer: "local" | "kimi" | "pi"; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
97
+ /** Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text. */
98
+ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string): ConductorStart {
99
+ if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new Error("mode must be headless or tui");
100
+ if (peer === "claude" || peer === "codex" || (peer === "pi" && mode === "tui")) return { peer, mode: "tui", command: preview(peer) };
101
+ if ((peer === "local" || peer === "kimi" || peer === "pi") && mode !== "tui") return { peer, mode: "headless" };
102
+ throw new Error("only local, kimi and headless pi may be started by the conductor");
103
+ }
104
+
105
+ const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
106
+ export interface ConductorStatusInput {
107
+ peers: Array<{ peer: string; state?: string; attached?: boolean; queued?: number; needsReview?: number; manualHeld?: boolean; hold?: ConductorHold | null; budgetPause?: { since?: number; resetsAt?: number } | null; windows?: Array<{ id: string; used?: number; resetsAt?: number; at?: number; source?: string }> }>;
108
+ taskCounts: Record<string, number>;
109
+ approvals: Array<{ peer: string; tool?: string; at?: number }>;
110
+ }
111
+ /** Explicit projection, never spreads a queue, permission request or quota pause containing private text. */
112
+ export function publicConductorStatus(input: ConductorStatusInput, now = Date.now()): Record<string, unknown> {
113
+ return {
114
+ peers: input.peers.map((p) => ({
115
+ peer: p.peer, state: p.state ?? "unknown", attached: p.attached ?? null,
116
+ queued: numberOrNull(p.queued), needsReview: numberOrNull(p.needsReview),
117
+ manualHold: p.manualHeld === undefined ? null : { held: p.manualHeld, actor: p.manualHeld ? "user" : null },
118
+ conductorHold: p.hold ? { actor: p.hold.actor, since: numberOrNull(p.hold.since) } : null,
119
+ budgetPause: p.budgetPause ? { since: numberOrNull(p.budgetPause.since), resetsAt: numberOrNull(p.budgetPause.resetsAt) } : null,
120
+ windows: p.windows === undefined ? null : p.windows.map((w) => ({ id: w.id, used: numberOrNull(w.used), resetsAt: numberOrNull(w.resetsAt), at: numberOrNull(w.at), source: w.source ?? null })),
121
+ })),
122
+ taskCounts: Object.fromEntries(Object.entries(input.taskCounts).map(([state, count]) => [state, numberOrNull(count)])),
123
+ approvals: input.approvals.map((a) => ({ peer: a.peer, tool: a.tool ?? null, ageMs: a.at === undefined || !Number.isFinite(a.at) ? null : Math.max(0, now - a.at) })),
124
+ };
125
+ }
126
+
127
+ /** publicView must use Tasks.publicView(task, true), including its screened history. */
128
+ export function publicConductorTask(task: Task, publicView: (task: Task) => Record<string, unknown>): Record<string, unknown> {
129
+ const view = publicView(task);
130
+ if (view.title === "[pii]") return { id: task.id, title: "[pii]", detail: "[pii]", class: task.class, state: task.state, owner: task.owner, reviewer: task.reviewer, history: [] };
131
+ return { ...view, history: Array.isArray(view.history) ? view.history : [] };
132
+ }
133
+
134
+ export interface ConductEvent {
135
+ kind: "conduct"; actor: string; action: string; task?: number; peer?: string;
136
+ }
137
+ export interface ConductorHooks {
138
+ roles(): unknown;
139
+ capabilities(): Record<string, unknown>;
140
+ status(): ConductorStatusInput;
141
+ task(id: number): Task | undefined;
142
+ publicView(task: Task): Record<string, unknown>;
143
+ /** These callbacks must call Tasks, never Board.update. */
144
+ assign(actor: string, id: number, peer: string): Promise<unknown>;
145
+ escalate(actor: string, id: number): Promise<unknown>;
146
+ preview(peer: "claude" | "codex" | "pi"): string;
147
+ start(peer: "local" | "kimi" | "pi"): Promise<unknown>;
148
+ known(peer: string): boolean;
149
+ pause(peer: string): void;
150
+ validateRelease?(peer: string): Promise<void>;
151
+ /** Re-check manual, budget, recovery and remaining conductor holds before Bus.resume. */
152
+ release(peer: string): void;
153
+ /** Log and console notice plus events.jsonl, using this ids-only payload. Must not throw. */
154
+ audit(event: ConductEvent): void;
155
+ }
156
+
157
+ export class Conductor {
158
+ constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
159
+ async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
160
+ if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new Error("unknown conductor tool");
161
+ requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate");
162
+ const action = tool.slice(4);
163
+ const emit = (extra: Pick<ConductEvent, "task" | "peer"> = {}) => {
164
+ try { this.hooks.audit({ kind: "conduct", actor, action, ...extra }); } catch { /* never throw after a task or hold write */ }
165
+ };
166
+ if (tool === "hub_status") { const result = publicConductorStatus(this.hooks.status()); emit(); return result; }
167
+ if (tool.startsWith("hub_task_")) {
168
+ if (typeof args.id !== "number" || !Number.isSafeInteger(args.id) || args.id <= 0) throw new Error("id must be a positive integer");
169
+ const task = this.hooks.task(args.id);
170
+ if (!task) throw new Error(`no task #${args.id}`);
171
+ if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
172
+ if (tool === "hub_task_assign") {
173
+ const peer = peerId(args.peer);
174
+ await this.hooks.assign(actor, task.id, peer); emit({ task: task.id, peer });
175
+ } else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
176
+ // Task callbacks may return raw objects; always re-read and apply the public view.
177
+ const updated = this.hooks.task(task.id);
178
+ return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
179
+ }
180
+ if (tool === "hub_peer_start") {
181
+ const start = conductorStart(args.peer, args.mode, this.hooks.preview);
182
+ if (start.mode === "headless") await this.hooks.start(start.peer);
183
+ emit({ peer: start.peer }); return start;
184
+ }
185
+ const peer = peerId(args.peer);
186
+ if (!this.hooks.known(peer)) throw new Error(`unknown peer: ${peer}`);
187
+ if (tool === "hub_peer_hold") { this.holds.hold(peer, actor); this.hooks.pause(peer); }
188
+ else {
189
+ if (this.holds.get(peer)?.actor !== actor) throw new Error("you may release only a conductor hold you placed");
190
+ await this.hooks.validateRelease?.(peer);
191
+ requireConductor(actor, this.hooks.roles(), this.hooks.capabilities());
192
+ this.holds.release(peer, actor); this.hooks.release(peer);
193
+ }
194
+ emit({ peer }); return { peer, held: this.holds.has(peer) };
195
+ }
196
+ }
@@ -6,10 +6,10 @@ export function stateDirFor(cwd: string): string {
6
6
  return projectContext(cwd).stateDir;
7
7
  }
8
8
 
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). The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 14;
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). The plugin is installed apart from the daemon, so they can drift. */
10
+ export const PROTOCOL = 15;
11
11
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
12
- export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, PROTOCOL] as const;
12
+ export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, PROTOCOL] as const;
13
13
 
14
14
  export interface Hello {
15
15
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */