@staix/agent-hub 0.12.15 → 0.12.17

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 (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/budget.md +5 -1
  5. package/docs/agent-notes/bus.md +2 -0
  6. package/docs/agent-notes/tasks.md +3 -1
  7. package/docs/agent-notes/tests.md +3 -1
  8. package/docs/operations.md +277 -11
  9. package/docs/quickstart.md +4 -4
  10. package/docs/security.md +26 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +168 -0
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +18 -6
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +10 -2
  21. package/src/adapters/local-worker.ts +5 -5
  22. package/src/adapters/pi.ts +4 -14
  23. package/src/cli/console-state.ts +213 -0
  24. package/src/cli/console.ts +201 -0
  25. package/src/cli/facts-hook.ts +8 -1
  26. package/src/cli/identity-audit.ts +66 -0
  27. package/src/cli/identity.ts +54 -0
  28. package/src/cli/init.ts +40 -30
  29. package/src/cli/launch.ts +31 -1
  30. package/src/cli/main.ts +95 -39
  31. package/src/cli/preview.ts +80 -0
  32. package/src/cli/status-lines.ts +8 -2
  33. package/src/cli/statusline-tee.ts +14 -0
  34. package/src/cli/tail-render.ts +17 -0
  35. package/src/cli/upgrade-runtime.ts +1 -1
  36. package/src/hub/board.ts +9 -2
  37. package/src/hub/bus.ts +82 -10
  38. package/src/hub/child-process.ts +11 -0
  39. package/src/hub/conductor.ts +196 -0
  40. package/src/hub/context-window.ts +72 -0
  41. package/src/hub/control-client.ts +3 -3
  42. package/src/hub/daemon.ts +492 -77
  43. package/src/hub/envelope.ts +1 -1
  44. package/src/hub/events.ts +7 -1
  45. package/src/hub/hub-tools.ts +14 -1
  46. package/src/hub/report.ts +53 -4
  47. package/src/hub/supervision.ts +152 -0
  48. package/src/hub/task-sweep.ts +59 -0
  49. package/src/hub/tasks.ts +86 -15
  50. package/src/hub/usage.ts +37 -2
  51. package/src/models/relay.ts +6 -1
  52. package/src/pi/launch.ts +21 -0
  53. package/src/ui/index.html +6 -0
package/src/hub/bus.ts CHANGED
@@ -82,6 +82,8 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
82
82
 
83
83
  const SEEN_CAP = 2048;
84
84
  const MAX_ATTEMPTS = 3;
85
+ // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
86
+ // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
85
87
  const DIGEST_MAX = 10;
86
88
  const NOTES_KEPT = 10;
87
89
 
@@ -100,6 +102,8 @@ export class Bus {
100
102
  private readonly manualPaused = new Set<PeerId>();
101
103
  private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
102
104
  private readonly draining = new Set<PeerId>();
105
+ /** Still pending while an asynchronous condensation runs; never includes native dispatches. */
106
+ private readonly preparing = new Map<PeerId, Envelope[]>();
103
107
  private readonly seen = new Map<string, Envelope>();
104
108
  private readonly taps = new Set<(e: BusEvent) => void>();
105
109
  private readonly withdrawn = new Set<string>();
@@ -119,6 +123,10 @@ export class Bus {
119
123
  * and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
120
124
  */
121
125
  onQueues?: () => void;
126
+ /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
127
+ onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
128
+ /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
129
+ onDeliveryFailed?: (peer: PeerId) => void;
122
130
  private recoveryHeld = false;
123
131
  private steering = 0;
124
132
  private condensing = 0;
@@ -389,7 +397,9 @@ export class Bus {
389
397
  const id = receipt.id;
390
398
  const before = this.journal.get(id);
391
399
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
400
+ if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
392
401
  this.journal.transition(id, state, receipt.reason);
402
+ if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
393
403
  if (state === "failed") {
394
404
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
395
405
  }
@@ -525,7 +535,8 @@ export class Bus {
525
535
  this.activeDeliveries.set(deliveryId, id);
526
536
  if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
527
537
  const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
528
- steerResult.then(() => {}).catch(() => {
538
+ steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
539
+ this.deliveryFailed(id);
529
540
  if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
530
541
  this.activeDeliveries.delete(deliveryId);
531
542
  if (!this.journal) this.enqueue(id, env, true);
@@ -570,8 +581,7 @@ export class Bus {
570
581
  * dropped if the refused steer tries to queue it later.
571
582
  */
572
583
  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);
584
+ this.markWithdrawn(envelopeId);
575
585
  let removed = false;
576
586
  for (const queue of this.queues.values()) {
577
587
  const i = queue.findIndex((e) => e.id === envelopeId);
@@ -582,6 +592,49 @@ export class Bus {
582
592
  return removed;
583
593
  }
584
594
 
595
+ /** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
596
+ revokeSupervision(peer: PeerId): number {
597
+ const queue = this.queues.get(peer);
598
+ if (!queue && !this.preparing.has(peer)) return 0;
599
+ let removed = 0;
600
+ const discarded = new Set<string>();
601
+ for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
602
+ const env = queue![i]!;
603
+ if (env.from !== HUB || !env.refs?.supervision) continue;
604
+ queue!.splice(i, 1);
605
+ removed++;
606
+ discarded.add(env.id);
607
+ this.markWithdrawn(env.id);
608
+ this.discardQueued(peer, env, "supervision role or feed revoked");
609
+ }
610
+ for (const env of this.preparing.get(peer) ?? []) {
611
+ if (env.from !== HUB || !env.refs?.supervision || discarded.has(env.id)) continue;
612
+ this.markWithdrawn(env.id);
613
+ this.discardQueued(peer, env, "supervision role or feed revoked during preparation");
614
+ removed++;
615
+ }
616
+ if (removed) { this.persist(); this.onQueues?.(); }
617
+ return removed;
618
+ }
619
+
620
+ private discardQueued(peer: PeerId, env: Envelope, reason: string): void {
621
+ if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "discarded", createdAt: Date.now(), originals: [env], out: [], reason });
622
+ this.emit({ t: "stale", env, peer, reason });
623
+ }
624
+
625
+ private delivered(peer: PeerId, originals: Envelope[]): void {
626
+ try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
627
+ }
628
+
629
+ private deliveryFailed(peer: PeerId): void {
630
+ try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
631
+ }
632
+
633
+ private markWithdrawn(id: string): void {
634
+ this.withdrawn.add(id);
635
+ if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
636
+ }
637
+
585
638
  queued(id: PeerId): number {
586
639
  return this.queues.get(id)?.length ?? 0;
587
640
  }
@@ -599,8 +652,23 @@ export class Bus {
599
652
  if (this.withdrawn.has(env.id)) return;
600
653
  const queue = this.queues.get(id) ?? [];
601
654
  this.queues.set(id, queue);
602
- if (front) queue.unshift(env);
603
- else queue.push(env);
655
+ const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
656
+ const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
657
+ 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;
658
+ let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
659
+ if (replace >= 0) {
660
+ const old = queue[replace]!;
661
+ // Keep the first milestone's deadline, so continuous updates cannot create another batching window.
662
+ queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
663
+ queue[replace] = queuedEnv;
664
+ this.markWithdrawn(old.id);
665
+ this.discardQueued(id, old, "superseded supervision milestone");
666
+ } else if (front) queue.unshift(queuedEnv);
667
+ else queue.push(queuedEnv);
668
+ if (preparing && !this.withdrawn.has(preparing.id)) {
669
+ this.markWithdrawn(preparing.id);
670
+ this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
671
+ }
604
672
  if (queue.length > this.opts.queueCap) {
605
673
  const victim = queue.findIndex((e) => e.priority !== "important");
606
674
  const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
@@ -614,7 +682,8 @@ export class Bus {
614
682
 
615
683
  /** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
616
684
  private wait(queue: Envelope[]): number {
617
- if (queue.length >= this.opts.batchMax || queue.some((e) => e.priority === "important")) return 0;
685
+ const supervisionOnly = queue.every((e) => e.from === HUB && e.refs?.supervision && e.priority === "status");
686
+ if ((!supervisionOnly && queue.length >= this.opts.batchMax) || queue.some((e) => e.priority === "important")) return 0;
618
687
  return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
619
688
  }
620
689
 
@@ -656,6 +725,7 @@ export class Bus {
656
725
  // may checkpoint during that wait; it must not checkpoint the batch out of existence.
657
726
  if (!this.journal) this.prefaces.delete(id);
658
727
  const batch = this.take(id, queue, !this.journal);
728
+ this.preparing.set(id, batch);
659
729
  const delivery = preface ? [preface, ...batch] : batch;
660
730
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
661
731
  this.condensing += mayCondense ? 1 : 0;
@@ -679,11 +749,12 @@ export class Bus {
679
749
  for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
680
750
  while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
681
751
  this.lastDelivery.set(id, { out, originals: delivery });
752
+ this.preparing.delete(id); // from here on the transport/journal owns settlement
682
753
  const deliveryId = crypto.randomUUID();
683
754
  if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
684
755
  try {
685
756
  if (this.journal) await peer.deliver(out, deliveryId);
686
- else await peer.deliver(out);
757
+ else { await peer.deliver(out); this.delivered(id, delivery); }
687
758
  } catch {
688
759
  if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
689
760
  else this.failed(id, delivery);
@@ -691,11 +762,11 @@ export class Bus {
691
762
  }
692
763
  }
693
764
  } catch { this.storageError = "delivery journal unavailable"; }
694
- finally { this.draining.delete(id); this.onQueues?.(); }
765
+ finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
695
766
  }
696
767
 
697
768
  private isRelevant(id: PeerId, env: Envelope): boolean {
698
- return this.opts.relevant?.(id, env) ?? true;
769
+ return !this.withdrawn.has(env.id) && (this.opts.relevant?.(id, env) ?? true);
699
770
  }
700
771
 
701
772
  /**
@@ -704,7 +775,7 @@ export class Bus {
704
775
  * this recipient's copy goes: the other queues keep theirs, and no other delivery record changes.
705
776
  */
706
777
  private dropIrrelevant(id: PeerId, queue: Envelope[]): boolean {
707
- if (!this.opts.relevant) return false;
778
+ if (!this.opts.relevant && !this.withdrawn.size) return false;
708
779
  let dropped = false;
709
780
  for (let i = queue.length - 1; i >= 0; i--) {
710
781
  const env = queue[i]!;
@@ -757,6 +828,7 @@ export class Bus {
757
828
  }
758
829
 
759
830
  private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
831
+ if (!this.journal) this.deliveryFailed(id);
760
832
  this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
761
833
  if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
762
834
  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
+ }
@@ -0,0 +1,72 @@
1
+ /** Native active-context occupancy, separate from accumulated billable usage (#185). */
2
+ export interface ContextReading {
3
+ source: "claude_statusline" | "codex_token_usage";
4
+ sessionId: string;
5
+ measuredAt: number;
6
+ tokens: number | null;
7
+ window: number | null;
8
+ used: number | null;
9
+ }
10
+ export interface ContextView {
11
+ source: string | null;
12
+ measuredAt: number | null;
13
+ tokens: number | null;
14
+ window: number | null;
15
+ used: number | null;
16
+ freshness: "fresh" | "stale" | "unknown";
17
+ }
18
+ export interface ContextConfig { gate: number; stale_min: number }
19
+ export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
20
+ const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
21
+ const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
22
+ const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
23
+ const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
24
+ export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
25
+ const c = object(value);
26
+ const current = object(c.current_usage);
27
+ const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
28
+ const window = positive(c.context_window_size) ? c.context_window_size : null;
29
+ const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
30
+ const tokens = nonnegative(sum) ? sum : null;
31
+ const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
32
+ return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
33
+ }
34
+ export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
35
+ const c = object(value), last = object(c.last);
36
+ const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
37
+ const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
38
+ return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
39
+ }
40
+ export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
41
+ /** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
42
+ export class ContextWindows {
43
+ private readings = new Map<string, ContextReading>();
44
+ private high = new Map<string, { sessionId: string; crossed: boolean }>();
45
+ constructor(private cfg: ContextConfig, private crossing: (peer: string, reading: ContextReading) => unknown, private now: () => number = Date.now) {
46
+ if (!Number.isFinite(cfg.gate) || cfg.gate < 0 || cfg.gate > 1 || !Number.isFinite(cfg.stale_min) || cfg.stale_min <= 0) throw new Error("context: gate must be 0..1 and stale_min positive");
47
+ }
48
+ report(peer: string, reading: ContextReading, currentSession: string | undefined): void {
49
+ if (!currentSession || reading.sessionId !== currentSession) return;
50
+ const previous = this.readings.get(peer);
51
+ if (previous?.sessionId === reading.sessionId && positive(previous.measuredAt) && previous.measuredAt <= this.now() && positive(reading.measuredAt) && reading.measuredAt <= this.now() && previous.measuredAt > reading.measuredAt) return;
52
+ this.readings.set(peer, reading);
53
+ if (!positive(reading.measuredAt) || reading.measuredAt > this.now() || reading.used === null || !nonnegative(reading.used) || reading.used > 1 || this.now() - reading.measuredAt > this.cfg.stale_min * 60_000 || !this.cfg.gate) return;
54
+ const state = this.high.get(peer);
55
+ const crossed = state?.sessionId === reading.sessionId && state.crossed;
56
+ const over = reading.used >= this.cfg.gate;
57
+ if (over && !crossed && this.crossing(peer, reading) === false) return;
58
+ this.high.set(peer, { sessionId: reading.sessionId, crossed: over });
59
+ }
60
+ /** Reconsider a held crossing after release, using only the still-fresh reading and current native session. */
61
+ retry(peer: string, currentSession: string | undefined): void {
62
+ const reading = this.readings.get(peer);
63
+ if (reading) this.report(peer, reading, currentSession);
64
+ }
65
+ view(peer: string, currentSession: string | undefined, attached: boolean): ContextView {
66
+ const r = this.readings.get(peer);
67
+ if (!r) return unknownContext();
68
+ const stale = this.now() - r.measuredAt > this.cfg.stale_min * 60_000;
69
+ const valid = positive(r.measuredAt) && r.measuredAt <= this.now() && attached && !!currentSession && r.sessionId === currentSession && !stale;
70
+ return { source: r.source, measuredAt: positive(r.measuredAt) ? r.measuredAt : null, tokens: valid ? r.tokens : null, window: r.window, used: valid ? r.used : null, freshness: !valid ? stale ? "stale" : "unknown" : r.used === null ? "unknown" : "fresh" };
71
+ }
72
+ }
@@ -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). The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 13;
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, 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. */