@staix/agent-hub 0.12.2 → 0.12.4

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.
package/src/hub/daemon.ts CHANGED
@@ -6,7 +6,9 @@ import { spawn } from "node:child_process";
6
6
  import { childEnv } from "./child-process.ts";
7
7
  import { runCheck } from "./checks.ts";
8
8
  import { stripUntrusted } from "./config-trust.ts";
9
- import { eventLog, tokenDeltas } from "./events.ts";
9
+ import { eventLog, readEvents, tokenDeltas } from "./events.ts";
10
+ import { ExecutionBudget } from "./execution-budget.ts";
11
+ import { readClaudeTranscriptUsage } from "./usage.ts";
10
12
  import type { ServerWebSocket } from "bun";
11
13
  import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
12
14
  import { CodexPeer } from "../adapters/codex-appserver.ts";
@@ -26,7 +28,9 @@ import { Board, CLASSES, type Task, type TaskClass } from "./board.ts";
26
28
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
27
29
  import { HUB } from "./envelope.ts";
28
30
  import { trimToTokens } from "../memory/recall.ts";
29
- import { statSync } from "node:fs";
31
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
32
+ import { basename, isAbsolute } from "node:path";
33
+ import { realPath } from "./project.ts";
30
34
  import type { BusEvent } from "./bus.ts";
31
35
  import { DEFAULT_ROLES, roleContract, TASK_TOOLS } from "./hub-tools.ts";
32
36
  import { Tasks } from "./tasks.ts";
@@ -43,6 +47,7 @@ import { MemoryClient, workerUrl } from "../memory/client.ts";
43
47
  import { VERSION } from "../version.ts";
44
48
  import { projectChain, recallFor } from "../memory/recall.ts";
45
49
  import { conflictsOf } from "./conflicts.ts";
50
+ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
46
51
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
47
52
  import type { JournalDelivery } from "./delivery-journal.ts";
48
53
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
@@ -81,6 +86,13 @@ export interface HubConfig {
81
86
  recovery: { auto_resume_after_crash: boolean };
82
87
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
83
88
  capabilities: Record<string, string[]>;
89
+ /**
90
+ * How owners of overlapping tasks coordinate (issue #107): "advisory" lets them message each other; "turn-free" has
91
+ * them work without messages while the hub derives what they need to know. Anything else is advisory.
92
+ */
93
+ coordination: "advisory" | "turn-free";
94
+ /** Switches for controlled ablations only (issue #110): `stale_notices: "deliver"` turns #106's dropping off. */
95
+ experiments?: { stale_notices?: "drop" | "deliver" };
84
96
  /** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
85
97
  ignored?: string[];
86
98
  /** Settings that were removed or are about to be (issue #83): what each does now, for `hub.log` and `ahub doctor`. */
@@ -111,6 +123,7 @@ export const DEFAULT_CONFIG: HubConfig = {
111
123
  review: { adaptive: false, min_reviews: 5 },
112
124
  recovery: { auto_resume_after_crash: false },
113
125
  capabilities: {},
126
+ coordination: "advisory",
114
127
  };
115
128
 
116
129
  export { stateDirFor };
@@ -218,6 +231,8 @@ type Sock = ServerWebSocket<Client>;
218
231
 
219
232
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
220
233
  class WsPeer extends BasePeer {
234
+ private generation = crypto.randomUUID();
235
+ private delivered = new Set<string>();
221
236
  private sock: Sock | undefined;
222
237
  private claimed: Sock | undefined;
223
238
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
@@ -231,8 +246,11 @@ class WsPeer extends BasePeer {
231
246
  }
232
247
  attach(sock: Sock): void {
233
248
  if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
249
+ if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
234
250
  this.sock?.close(4000, "replaced");
235
251
  this.sock = sock;
252
+ this.generation = crypto.randomUUID();
253
+ this.delivered.clear();
236
254
  this.setState("idle");
237
255
  }
238
256
  detach(sock: Sock): void {
@@ -242,9 +260,11 @@ class WsPeer extends BasePeer {
242
260
  }
243
261
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
244
262
  if (!this.sock) throw new Error(`${this.id} is offline`);
245
- this.sock.send(JSON.stringify({ t: "deliver", envs, ...(deliveryId ? { deliveryId } : {}) }));
263
+ if (deliveryId) this.delivered.add(deliveryId);
264
+ this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
246
265
  }
247
266
  owns(sock: Sock): boolean { return this.sock === sock; }
267
+ ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
248
268
  async start(): Promise<void> {}
249
269
  async stop(): Promise<void> {
250
270
  this.sock?.close();
@@ -341,6 +361,25 @@ export async function startDaemon(opts: DaemonOptions) {
341
361
  }
342
362
  // Agents only: the console user and the hub itself are never limited (issue #38).
343
363
  // A typo such as "12/min" would read as 0, which turns a limit off without a word: the project default instead.
364
+ // Turn-free coordination (issue #107): owners of overlapping tasks work without messages.
365
+ const coordination = config.coordination === "turn-free" ? "turn-free" : "advisory";
366
+ if (config.coordination !== coordination) log(`coordination: ${JSON.stringify(config.coordination)} is not "advisory" or "turn-free"; advisory applies`);
367
+ // While any PII task is open the regime is advisory (issue #108): facts would carry file contents to cloud peers, and
368
+ // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
369
+ // together. Cached until the board changes: it is asked per recipient of every message.
370
+ let turnFreeNow: boolean | undefined;
371
+ const turnFree = (): boolean => (turnFreeNow ??= coordination === "turn-free" && !board.list().some((t) => t.state !== "approved" && tasks.isPii(t)));
372
+ /**
373
+ * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
374
+ * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
375
+ */
376
+ const silence = (env: Envelope, to: PeerId): string | undefined => {
377
+ if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
378
+ const cohort = tasks.silenced(env.from, to);
379
+ if (!cohort) return undefined;
380
+ const ids = [...cohort.members.keys()].sort((a, b) => a - b).map((id) => `#${id}`).join(", ");
381
+ return `you and ${to} are in turn-free cohort #${cohort.id} (tasks ${ids}): owners in a cohort do not message each other; the hub shows each of you the other's changes and asks the last to finish to check the work against the others. The console user reads it in the log.`;
382
+ };
344
383
  for (const k of Object.keys(config.limits)) if (!(k in PROJECT_LIMITS)) log(`limits.${k} is not a known limit; ignored`);
345
384
  const limits = Object.fromEntries(Object.entries(PROJECT_LIMITS).map(([k, fallback]) => {
346
385
  const v = config.limits[k as keyof LimitsConfig];
@@ -370,7 +409,13 @@ export async function startDaemon(opts: DaemonOptions) {
370
409
  if (refused) log(`limits: ${env.from}: ${refused}`);
371
410
  return refused;
372
411
  };
373
- const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit });
412
+ /** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
413
+ const hubStartedAt = Date.now();
414
+ // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
415
+ let relevantNotice: (peer: PeerId, env: Envelope) => boolean = () => true;
416
+ const staleOff = config.experiments?.stale_notices === "deliver";
417
+ if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
418
+ const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => staleOff || relevantNotice(peer, env), silence });
374
419
  startupCleanup.push(() => bus.closeJournal());
375
420
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
376
421
  let recoveryOperationId: string | undefined;
@@ -441,6 +486,8 @@ export async function startDaemon(opts: DaemonOptions) {
441
486
  };
442
487
  const board = new Board(join(opts.stateDir, "hub.db"));
443
488
  startupCleanup.push(() => board.close());
489
+ const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
490
+ startupCleanup.push(() => executionBudget.close());
444
491
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
445
492
  const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
446
493
  const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
@@ -453,9 +500,10 @@ export async function startDaemon(opts: DaemonOptions) {
453
500
  const runningChecks = new Set<() => void>();
454
501
  let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
455
502
  const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
456
- const tasks = new Tasks({
503
+ const tasks: Tasks = new Tasks({
457
504
  board,
458
505
  bus,
506
+ executionBudget,
459
507
  routing: () => currentRouting(opts.cwd, log), // routing.toml is edited while the hub runs: re-read on change, last good parse kept
460
508
  cwd: opts.cwd,
461
509
  project: chain.at(-1)!,
@@ -483,10 +531,170 @@ export async function startDaemon(opts: DaemonOptions) {
483
531
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
484
532
  review: config.review,
485
533
  roles: config.roles,
534
+ turnFree,
535
+ capable: (peer) => capable.has(peer),
536
+ idle: (peer) => idle(peer),
537
+ treeHash: (paths, windows) => facts.tree(paths, windows),
538
+ integrationFacts: (peer) => {
539
+ if (!factsOn()) return undefined;
540
+ let offered: ReturnType<Facts["due"]>;
541
+ try { offered = facts.due(peer, undefined, true); } catch (error) { log(`integration facts for ${peer}: ${(error as Error).message}`); return undefined; }
542
+ if (offered) event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, bytes: offered.bytes, via: "done" });
543
+ return offered ? { id: offered.id, text: offered.text } : undefined;
544
+ },
545
+ ackFacts: (peer, id) => acked(peer, id, "done"),
546
+ factsCurrent: (peer) => !factsOn() || facts.current(peer),
547
+ since: hubStartedAt,
548
+ recordCohort: (c) => event({ type: "cohort", ...c }),
549
+ // The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
550
+ recordSplit: (task, p) => event({ type: "split", task, verdict: p.verdict, ...(p.single ? { single: p.single, splitS: p.splitS, singleS: p.singleS } : {}), ...(p.verdict === "unknown" ? { reason: p.trace.at(-1)!.replace(/^ {2}unknown: /, "").slice(0, 200) } : {}), trace: p.trace.slice(1).map((l) => l.trim()).slice(0, 10) }),
486
551
  failing: () => bus.failingPeers(),
487
552
  held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
488
553
  });
554
+ relevantNotice = tasks.relevant;
555
+ tasks.recoverIntegrations();
556
+ /** Peers told, at their first attach, what the cohorts lost in the restart held for them (issue #107). */
557
+ const replayed = new Set<PeerId>();
558
+ // Turn-free facts (issue #108): what the others changed in an owner's files, at its tool calls. Scopes are read from
559
+ // the board once per change of it: every boundary asks for them.
560
+ let scopes = new Map<PeerId, FactScope | undefined>();
561
+ const facts: Facts = new Facts({ root: opts.cwd, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)), peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny });
562
+ /** Whether facts are tracked now. When tracking resumes (a PII task closed), everything observed before is dropped. */
563
+ let factsWereOn = false;
564
+ const factsOn = (): boolean => {
565
+ const on = turnFree();
566
+ if (on && !factsWereOn) facts.reset();
567
+ // A PII task opened: every silent cohort speaks again, for good, and its members hear so at once (issue #108).
568
+ if (!on && factsWereOn) for (const cohort of tasks.cohorts.liftAll()) tasks.announceLift(cohort, "a private task opened, and facts are off while it is open");
569
+ factsWereOn = on;
570
+ return on;
571
+ };
572
+ /** Peers whose context path is verified in their current native session: a readback found an offered fact there. */
573
+ const capable = new Set<PeerId>();
574
+ const probes = new Map<PeerId, number>();
575
+ const factSessions = new Map<PeerId, string>();
576
+ /** A new native session or thread: its context path is unverified until a readback says otherwise. */
577
+ const factSession = (peer: PeerId, id: string | undefined) => {
578
+ if (!id || factSessions.get(peer) === id) return;
579
+ if (factSessions.has(peer)) {
580
+ if (capable.delete(peer)) loseCapability(peer, "a new native session started");
581
+ probes.delete(peer);
582
+ }
583
+ factSessions.set(peer, id);
584
+ facts.session(peer, id);
585
+ };
586
+ const acked = (peer: PeerId, id: string, via: string) => {
587
+ const got = facts.ack(peer, id);
588
+ if (!got) return;
589
+ event({ type: "fact_ack", peer, id, via, ms: Date.now() - got.at });
590
+ if (via !== "done" && !capable.has(peer)) {
591
+ capable.add(peer);
592
+ event({ type: "capability", peer, state: "verified", via });
593
+ log(`turn-free: ${peer}'s context path is verified (${via} readback of ${id})`);
594
+ }
595
+ };
596
+ /** Silent cohorts with this peer speak again; their members hear so. */
597
+ const loseCapability = (peer: PeerId, why: string) => {
598
+ event({ type: "capability", peer, state: "lost" });
599
+ log(`turn-free: ${peer}'s context path is no longer verified (${why})`);
600
+ for (const cohort of tasks.cohorts.lift(peer)) tasks.announceLift(cohort, `${peer}'s context path is no longer verified (${why})`);
601
+ };
602
+ /**
603
+ * A verified peer with three offers past a minute and no readback has lost its context path. Facts sent with an
604
+ * integration request wait for the next done, not a readback, so they never count.
605
+ */
606
+ const checkCapability = (peer: PeerId) => {
607
+ if (capable.has(peer) && facts.pending(peer).filter((o) => !o.done && Date.now() - o.at > 60_000).length >= 3) {
608
+ capable.delete(peer);
609
+ loseCapability(peer, "three facts found no readback");
610
+ }
611
+ };
612
+ /**
613
+ * An offer for this boundary: the facts due, or, until the context path is verified, a probe (three per session).
614
+ * A peer without a verified path that left three offers unread gets none until a readback arrives: the same diff is
615
+ * not injected at every tool call.
616
+ */
617
+ const offerFor = (peer: PeerId, toolUseId?: string) => {
618
+ if (!capable.has(peer) && facts.pending(peer).filter((o) => !o.done).length >= 3) return undefined;
619
+ const due = facts.due(peer, toolUseId);
620
+ if (due || capable.has(peer) || facts.pending(peer).some((o) => o.probe) || (probes.get(peer) ?? 0) >= 3) return due;
621
+ probes.set(peer, (probes.get(peer) ?? 0) + 1);
622
+ return facts.probe(peer, toolUseId);
623
+ };
624
+ // Claude's readback: the transcript row Claude Code writes for a hook's additional context, by tool use id.
625
+ const injected = new Map<string, string>();
626
+ let transcriptRead = { path: "", offset: 0 };
627
+ const readTranscript = (path: string) => {
628
+ try {
629
+ if (transcriptRead.path !== path) transcriptRead = { path, offset: 0 };
630
+ const st = statSync(path);
631
+ if (st.size < transcriptRead.offset) transcriptRead.offset = 0;
632
+ if (st.size === transcriptRead.offset) return;
633
+ const fd = openSync(path, "r");
634
+ try {
635
+ const start = Math.max(transcriptRead.offset, st.size - 4 * 1024 * 1024);
636
+ const buf = Buffer.alloc(st.size - start);
637
+ readSync(fd, buf, 0, buf.length, start);
638
+ // Lines end at a newline byte: decoding first would misplace the offset after a split multi-byte character.
639
+ const end = buf.lastIndexOf(10);
640
+ if (end === -1) return;
641
+ const lines = buf.subarray(0, end).toString("utf8").split("\n");
642
+ if (start > transcriptRead.offset) lines.shift(); // the window began inside a line
643
+ transcriptRead.offset = start + end + 1;
644
+ for (const line of lines) {
645
+ if (!line.includes("hook_additional_context")) continue;
646
+ try {
647
+ const a = JSON.parse(line)?.attachment;
648
+ if (a?.type !== "hook_additional_context" || typeof a.toolUseID !== "string") continue;
649
+ injected.set(a.toolUseID, Array.isArray(a.content) ? a.content.join("\n") : String(a.content ?? ""));
650
+ if (injected.size > 512) injected.delete(injected.keys().next().value as string);
651
+ } catch { /* a partial or foreign line */ }
652
+ }
653
+ } finally { closeSync(fd); }
654
+ } catch { /* no transcript yet: no readback */ }
655
+ };
656
+ /** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
657
+ const claudeTranscript = (sessionId: string | undefined, fromHook: unknown): string | undefined => {
658
+ const reported = claudeSession();
659
+ if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
660
+ if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
661
+ try {
662
+ const projects = realPath(join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects"));
663
+ const real = realPath(fromHook);
664
+ return real.startsWith(`${projects}/`) && statSync(real).isFile() ? real : undefined;
665
+ } catch { return undefined; }
666
+ };
667
+ const readbacks = (peer: PeerId, transcript: string | undefined) => {
668
+ const pending = facts.pending(peer).filter((o) => o.toolUseId);
669
+ if (!pending.length || !transcript) return;
670
+ readTranscript(transcript);
671
+ for (const o of pending) if (injected.get(o.toolUseId!)?.includes(`[${o.id}]`)) acked(peer, o.id, "hook");
672
+ };
673
+ // Native turn ends (issue #107): Codex's is its adapter going idle, Claude's is its Stop hook. Never a task approval,
674
+ // never a delivery acknowledgement. Cohorts record them as they happen (`Cohorts.turnEnded`), so a later turn of the
675
+ // same peer cannot undo a settlement.
676
+ const turnEnded = new Map<PeerId, number>();
677
+ const activeAt = new Map<PeerId, number>();
678
+ /** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
679
+ const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
680
+ /**
681
+ * Between native turns now: Claude stopped after its last tool call began (its channel going offline says nothing
682
+ * about its session, whose hooks may still run); any other peer's adapter is not in a turn. The adapter's own state,
683
+ * never `stateOf`: a paused peer may still be in its turn.
684
+ */
685
+ const idle = (peer: PeerId): boolean => {
686
+ if (peer === "claude") return (turnEnded.get(peer) ?? -1) >= (activeAt.get(peer) ?? 0);
687
+ return (bus.peers.get(peer)?.state ?? "offline") !== "busy";
688
+ };
689
+ /** What quiescence was judged on, for the log when an integration waits on it. */
690
+ const stopEvidence = (peer: PeerId) => `${peer}: turn end ${turnEnded.has(peer) ? new Date(turnEnded.get(peer)!).toISOString() : "none"}, last tool call ${activeAt.has(peer) ? new Date(activeAt.get(peer)!).toISOString() : "none"}, ${bus.peers.get(peer)?.state ?? "offline"}`;
489
691
  board.onChange = (t, h) => {
692
+ turnFreeNow = undefined;
693
+ scopes = new Map();
694
+ factsOn(); // a PII task opening or closing switches tracking at once, not at the next boundary (issue #108)
695
+ if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
696
+ for (const m of tasks.cohorts.of(t.id)?.members.values() ?? []) if (m.task !== t.id) log(`turn-free: task #${t.id} waits on ${stopEvidence(m.owner)}`);
697
+ }
490
698
  event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, pii: tasks.isPii(t) });
491
699
  // Models can self-claim after their turn begins; preserve that ownership even if they finish before settlement.
492
700
  const turn = t.owner ? turns.get(t.owner) : undefined;
@@ -595,6 +803,8 @@ export async function startDaemon(opts: DaemonOptions) {
595
803
  let claudeUsageSeen = 0;
596
804
  const intervals = [
597
805
  setInterval(() => budget.tick(), 30_000),
806
+ // A verified context path whose hooks stopped altogether has no boundary left to notice it at (issue #108).
807
+ setInterval(() => { for (const peer of [...capable]) checkCapability(peer); }, 30_000),
598
808
  setInterval(() => {
599
809
  try {
600
810
  const file = join(opts.stateDir, "claude-usage.json");
@@ -678,18 +888,27 @@ export async function startDaemon(opts: DaemonOptions) {
678
888
  case "hub_task_propose": {
679
889
  const t = await tasks.propose(by, a);
680
890
  const overlap = tasks.overlaps(t, t.owner === by);
891
+ if (t.owner === by && tasks.silentFor(t.id) && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // as for an accept
681
892
  return overlap ? `${line(t)}\n${overlap}` : line(t);
682
893
  }
683
894
  case "hub_task_accept": {
684
895
  const t = tasks.accept(by, a.id, a.plan);
685
- const overlap = a.plan == null ? "" : tasks.overlaps(t);
896
+ // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
897
+ const silent = tasks.silentFor(t.id);
898
+ const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
899
+ if (silent && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
686
900
  return overlap ? `${line(t)}\n${overlap}` : line(t);
687
901
  }
688
902
  case "hub_task_decline":
689
903
  return line(await tasks.decline(by, a.id, a.reason));
690
904
  case "hub_task_done": {
691
905
  const t = await tasks.done(by, a.id, a.summary, a.refs);
692
- return tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
906
+ // A silent cohort (issue #107): the member that integrates is asked to check its work first; no done yet.
907
+ const last = t.history.at(-1);
908
+ if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
909
+ const extra = tasks.takeDoneNote(t.id); // the notices a silence held, when no integration step ran
910
+ const result = tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
911
+ return extra ? `${result}\n${extra}` : result;
693
912
  }
694
913
  case "hub_review":
695
914
  return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet));
@@ -807,6 +1026,20 @@ export async function startDaemon(opts: DaemonOptions) {
807
1026
  const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
808
1027
  return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
809
1028
  };
1029
+ // Optional native usage: only the instance-fenced explicit Claude session is read.
1030
+ // IDs persist in telemetry, so a restart/resume never counts the same native message twice.
1031
+ const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
1032
+ const collectClaudeUsage = () => {
1033
+ const session = claudeSession();
1034
+ if (!session.sessionId || !session.transcriptPath) return;
1035
+ for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
1036
+ if (claudeNativeUsageSeen.has(record.id)) continue;
1037
+ claudeNativeUsageSeen.add(record.id);
1038
+ event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
1039
+ }
1040
+ };
1041
+ const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
1042
+ claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
810
1043
  const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
811
1044
  const metadata = peer.recoveryMetadata?.() ?? {};
812
1045
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
@@ -855,7 +1088,7 @@ export async function startDaemon(opts: DaemonOptions) {
855
1088
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
856
1089
  codexProxyPort: opts.codexProxyPort,
857
1090
  peers: Object.fromEntries(
858
- bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
1091
+ bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
859
1092
  ),
860
1093
  ...(sidecar ? { switchyard: sidecar.status } : {}),
861
1094
  ...(modelRelay ? { models: modelRelay.status() } : {}),
@@ -901,7 +1134,6 @@ export async function startDaemon(opts: DaemonOptions) {
901
1134
 
902
1135
  // When each peer went offline; a peer never seen attached counts from hub start (issue #6).
903
1136
  const offlineSince = new Map<PeerId, number>();
904
- const hubStartedAt = Date.now();
905
1137
  const releaseGoneOwners = async () => {
906
1138
  const limit = config.tasks.release_after_min;
907
1139
  if (releasing || stopping || recoveryActive()) return;
@@ -932,6 +1164,8 @@ export async function startDaemon(opts: DaemonOptions) {
932
1164
  // before it. Warns both owners once per file and task; never blocks a write.
933
1165
  const conflictSeen = new Set<string>();
934
1166
  const turnTasks = new Map<string, number[]>();
1167
+ /** Work a conflict notice is about: open tasks and tasks in review (#91); a notice for anything else is dropped (#106). */
1168
+ const CONFLICT_STATES: Task["state"][] = ["proposed", "in_progress", "changes_requested", "in_review"];
935
1169
  const detectConcurrentConflicts = (record: TurnRecord, mine: Task[]) => {
936
1170
  for (const otherTurn of turnLog!.concurrentWith(record)) {
937
1171
  const otherTasks = (turnTasks.get(otherTurn.id) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
@@ -946,15 +1180,17 @@ export async function startDaemon(opts: DaemonOptions) {
946
1180
  for (const path of paths) conflictSeen.add(`concurrent:${pair}:${path}`);
947
1181
  const named = paths.filter(tasks.nameable);
948
1182
  const files = [...named, ...(paths.length > named.length ? [`${paths.length - named.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
949
- const text = `Concurrent edit: ${record.id} (task #${ours.id}) and ${otherTurn.id} (task #${theirs.id}) both include changes to ${files}. These snapshots do not attribute the changes to either peer. Check the working tree together before continuing.`;
1183
+ const together = tasks.silentFor(ours.id) && tasks.silentFor(theirs.id) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : "Check the working tree together before continuing.";
1184
+ const text = `Concurrent edit: ${record.id} (task #${ours.id}) and ${otherTurn.id} (task #${theirs.id}) both include changes to ${files}. These snapshots do not attribute the changes to either peer. ${together}`;
950
1185
  notify(`conflict: ${text}`);
951
1186
  event({ type: "conflict", peer: record.peer, task: ours.id, other: theirs.id, owner: otherTurn.peer, paths: named, concurrent: true, turns: [record.id, otherTurn.id] });
952
- [record.peer, otherTurn.peer].filter((owner) => owner !== USER && owner !== HUB).forEach((owner) => bus.publish(newEnvelope(HUB, text, { to: [owner], kind: "task" })));
1187
+ // Each owner hears it while its own task is open; once that task is closed the notice is dropped (issue #106).
1188
+ for (const [owner, task] of [[record.peer, ours.id], [otherTurn.peer, theirs.id]] as const) if (owner !== USER && owner !== HUB) tasks.whileOpen(owner, task, text, CONFLICT_STATES);
953
1189
  }
954
1190
  }
955
1191
  };
956
1192
  const detectConflicts = (peer: PeerId, turnId: string, since: number, changed: string[]) => {
957
- const open = board.list().filter((t) => t.owner && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state));
1193
+ const open = board.list().filter((t) => t.owner && CONFLICT_STATES.includes(t.state));
958
1194
  const mine = (turnTasks.get(turnId) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
959
1195
  if (mine.some((t) => tasks.isPii(t))) return; // a PII turn's files are nobody else's business
960
1196
  const visible = open.filter((t) => !tasks.isPii(t));
@@ -984,8 +1220,12 @@ export async function startDaemon(opts: DaemonOptions) {
984
1220
  const files = [...named, ...(hidden ? [`${hidden} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
985
1221
  notify(`conflict: ${peer}${ours} changed ${files}, which #${task.id} (owner ${owner}) changed before${others.length ? ` (concurrent: ${others.join(", ")})` : ""}`);
986
1222
  event({ type: "conflict", peer, ...(mine[0] ? { task: mine[0].id } : {}), other: task.id, owner, paths: named, concurrent: others.length > 0 });
987
- bus.publish(newEnvelope(HUB, `Your last turn${ours} changed ${files}, which ${owner}'s open task (${tasks.publicTitle(task)}) changed before it. Check that you did not overwrite that work, and settle it with ${owner} via hub_send.${concurrent}`, { to: [peer], kind: "task", ...(mine[0] ? { refs: { task: String(mine[0].id) } } : {}) }));
988
- if (owner !== USER && owner !== HUB) bus.publish(newEnvelope(HUB, `${peer}'s last turn${ours} changed ${files}, which your open task #${task.id} changed before it. Check that your work there is intact.${concurrent}`, { to: [owner], kind: "task", refs: { task: String(task.id) } }));
1223
+ // Both notices are about an open task of their recipient: dropped at delivery once it is closed (issue #106).
1224
+ const settle = mine[0] && tasks.silentFor(mine[0].id) && tasks.silentFor(task.id) ? `; do not message ${owner} (you are in one turn-free cohort): the hub shows you its changes` : `, and settle it with ${owner} via hub_send`;
1225
+ const toPeer = `Your last turn${ours} changed ${files}, which ${owner}'s open task (${tasks.publicTitle(task)}) changed before it. Check that you did not overwrite that work${settle}.${concurrent}`;
1226
+ if (mine[0]) tasks.whileOpen(peer, mine[0].id, toPeer, CONFLICT_STATES);
1227
+ else bus.publish(newEnvelope(HUB, toPeer, { to: [peer], kind: "task" }));
1228
+ if (owner !== USER && owner !== HUB) tasks.whileOpen(owner, task.id, `${peer}'s last turn${ours} changed ${files}, which your open task #${task.id} changed before it. Check that your work there is intact.${concurrent}`, CONFLICT_STATES);
989
1229
  }
990
1230
  };
991
1231
 
@@ -1070,10 +1310,20 @@ export async function startDaemon(opts: DaemonOptions) {
1070
1310
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
1071
1311
  }
1072
1312
  event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
1313
+ if (e.peer !== "claude") { // Claude's native turn end is its Stop hook
1314
+ turnEnded.set(e.peer, Date.now());
1315
+ tasks.cohorts.turnEnded(e.peer);
1316
+ }
1073
1317
  try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
1074
1318
  }
1075
1319
  if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
1076
1320
  else offlineSince.delete(e.peer);
1321
+ // A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
1322
+ if (e.state === "offline" && capable.delete(e.peer)) loseCapability(e.peer, "it went offline");
1323
+ if (e.state !== "offline" && coordination === "turn-free" && !replayed.has(e.peer)) {
1324
+ replayed.add(e.peer);
1325
+ tasks.replayHeld(e.peer);
1326
+ }
1077
1327
  // After a crash, a peer's first attach brings the loss notice: it leads its next delivery (issue #37).
1078
1328
  if (e.state !== "offline" && lost.has(e.peer)) {
1079
1329
  const still = lost.get(e.peer)!.filter((d) => { try { return journal.get(d.id)?.state === "needs_review"; } catch { return false; } });
@@ -1082,6 +1332,14 @@ export async function startDaemon(opts: DaemonOptions) {
1082
1332
  }
1083
1333
  recordSessions();
1084
1334
  }
1335
+ else if (e.t === "stale") {
1336
+ log(`STALE ${e.peer}: dropped ${e.env.id} from ${e.env.from}: ${e.reason}`);
1337
+ event({ type: "stale", id: e.env.id, from: e.env.from, peer: e.peer, ...(e.env.refs?.task ? { task: e.env.refs.task } : {}) });
1338
+ }
1339
+ else if (e.t === "quiet") {
1340
+ log(`QUIET ${e.env.id} from ${e.env.from}: not delivered to ${e.peers.join(", ")} (turn-free cohort)`);
1341
+ event({ type: "quiet", id: e.env.id, from: e.env.from, peers: e.peers });
1342
+ }
1085
1343
  else if (e.t === "undeliverable" || e.t === "overflow") {
1086
1344
  log(e.t === "undeliverable" ? `UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}` : `OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
1087
1345
  event({ type: e.t, id: e.env.id, from: e.env.from, peer: e.peer });
@@ -1262,11 +1520,40 @@ export async function startDaemon(opts: DaemonOptions) {
1262
1520
  return { ok: true };
1263
1521
  }
1264
1522
  if (peer === "codex") {
1523
+ let codexSteering = false;
1265
1524
  const codex = new CodexPeer("codex", {
1266
1525
  onTokens: (added) => void addTokens("codex", added),
1267
1526
  onTurn: (native) => {
1268
1527
  const open = turns.get("codex");
1269
1528
  if (open) turnLog?.native(open.id, native);
1529
+ if (factsOn()) factSession("codex", codex.thread); // a new thread is unverified before its first item
1530
+ },
1531
+ // Turn-free facts (issue #108): its items are the boundaries; a fact goes into the running turn by steer, and
1532
+ // the steered input coming back as a user message item is its readback.
1533
+ onItem: (item) => {
1534
+ if (!factsOn()) return;
1535
+ factSession("codex", codex.thread);
1536
+ if (item.type === "userMessage") {
1537
+ const text = JSON.stringify(item.content ?? "");
1538
+ if (text.includes(FACTS_PREFIX)) for (const o of facts.pending("codex")) if (text.includes(`[${o.id}]`)) acked("codex", o.id, "steer");
1539
+ return;
1540
+ }
1541
+ facts.codexItem("codex", item);
1542
+ checkCapability("codex");
1543
+ if (codexSteering) return; // one at a time: the next boundary offers what this one could not
1544
+ const started = performance.now();
1545
+ const offered = offerFor("codex");
1546
+ if (!offered) return;
1547
+ const ms = Math.round(performance.now() - started);
1548
+ codexSteering = true;
1549
+ const sent = performance.now();
1550
+ void codex.steerText(offered.text).then((outcome) => {
1551
+ codexSteering = false;
1552
+ // Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
1553
+ // have: its readback can still come.
1554
+ if (outcome === "refused") facts.drop("codex", offered.id);
1555
+ event({ type: "fact", peer: "codex", id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, bytes: offered.bytes, via: "steer", ms, ...(outcome === "accepted" ? { rttMs: Math.round(performance.now() - sent) } : {}), accepted: outcome === "accepted", ...(outcome === "unanswered" ? { unanswered: true } : {}), ...(offered.probe ? { probe: true } : {}), ...(offered.coverage ? { coverage: true } : {}) });
1556
+ });
1270
1557
  },
1271
1558
  appPort: opts.codexAppPort,
1272
1559
  proxyPort: opts.codexProxyPort,
@@ -1297,7 +1584,7 @@ export async function startDaemon(opts: DaemonOptions) {
1297
1584
  const mode = args.mode ?? "headless";
1298
1585
  const backend = args.backend ?? config.pi.backend;
1299
1586
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
1300
- modelRelay ??= await startModelRelay({ omni, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast" });
1587
+ modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast" });
1301
1588
  piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
1302
1589
  let piReply: Envelope | undefined;
1303
1590
  const ctx: ToolContext = {
@@ -1316,14 +1603,24 @@ export async function startDaemon(opts: DaemonOptions) {
1316
1603
  cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
1317
1604
  model: args.model,
1318
1605
  sessionId: args.sessionId, sessionFile: args.sessionFile,
1606
+ admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, "pi", unit),
1319
1607
  relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
1320
1608
  tools: [...TOOL_SCHEMAS.map((t) => t.function), ...TASK_TOOLS.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
1321
- executeTool: async (name, raw, callId, sessionId) => {
1609
+ executeTool: async (name, raw, callId, sessionId, signal) => {
1610
+ if (signal?.aborted) return "error: turn cancelled before tool effects";
1322
1611
  if (stopping || (recoveryActive() && recoveryPhase !== "preparing")) return "error: recovery is holding tool effects";
1323
1612
  return piReceipts!.execute(sessionId ?? "", callId, name, raw, async () => {
1324
1613
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
1325
1614
  if (TASK_TOOLS.some((t) => t.name === name)) return taskOp("pi", name, raw as Record<string, unknown>, true);
1326
- return runTool(name, JSON.stringify(raw), ctx);
1615
+ return runTool(name, JSON.stringify(raw), { ...ctx, signal, permit: async title => {
1616
+ if (!signal) return ctx.permit(title);
1617
+ if (signal.aborted) return false;
1618
+ return new Promise<boolean>((resolve, reject) => {
1619
+ const finish = (allowed: boolean) => { signal.removeEventListener("abort", aborted); resolve(allowed && !signal.aborted); };
1620
+ const aborted = () => finish(false); signal.addEventListener("abort", aborted, { once: true });
1621
+ Promise.resolve(ctx.permit(title)).then(finish, error => { signal.removeEventListener("abort", aborted); reject(error); });
1622
+ });
1623
+ } });
1327
1624
  });
1328
1625
  },
1329
1626
  selectModel: async (envs) => {
@@ -1401,6 +1698,8 @@ export async function startDaemon(opts: DaemonOptions) {
1401
1698
  const local = new LocalPeer("local", {
1402
1699
  cwd: opts.cwd,
1403
1700
  omni,
1701
+ onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
1702
+ admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
1404
1703
  ...(sidecar && route ? { sidecar, route } : {}),
1405
1704
  fixedModel: args.model ?? routing.local.fixed_model,
1406
1705
  tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, permit },
@@ -1425,6 +1724,16 @@ export async function startDaemon(opts: DaemonOptions) {
1425
1724
  return { ok: false, error: `unknown peer "${peer}"` };
1426
1725
  }
1427
1726
 
1727
+ async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
1728
+ const current = bus.peers.get("pi");
1729
+ if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
1730
+ const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, "pi", "model_calls");
1731
+ const denied = decisions.find(d => !d.allowed);
1732
+ const stop = denied ? current.recordBudgetStop(denied) : undefined;
1733
+ const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
1734
+ return { allowed: !denied, ...(denied ? { reason: stop?.reason ?? `execution budget ${denied.reason}: ${denied.scope}` } : {}), ...(deadline.length ? { remainingMs: Math.min(...deadline) } : {}) };
1735
+ }
1736
+
1428
1737
  async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
1429
1738
  const routing = currentRouting(opts.cwd, log);
1430
1739
  if (route && !routing.routes[route]) throw new Error(`routing.toml has no route "${route}"`);
@@ -1729,10 +2038,30 @@ export async function startDaemon(opts: DaemonOptions) {
1729
2038
  }
1730
2039
  case "delivery_receipt": {
1731
2040
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
1732
- if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock) || typeof msg.deliveryId !== "string" || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
2041
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
1733
2042
  bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: "Claude bridge could not confirm notification delivery" } : {}) });
1734
2043
  return void reply({ ok: true });
1735
2044
  }
2045
+ case "delivery_complete": {
2046
+ const peer = c.peer ? bus.peers.get(c.peer) : undefined;
2047
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
2048
+ return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
2049
+ }
2050
+ case "execution_budget": {
2051
+ if (c.role !== "console") return void reply({ ok: false, error: "execution budgets are console-only" });
2052
+ if (msg.op === "status") return void reply({ ok: true, budgets: tasks.executionBudgetStatus(msg.id) });
2053
+ if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding budget mutations" });
2054
+ try {
2055
+ if (msg.op === "configure") {
2056
+ const cfg = msg.config;
2057
+ if (!cfg || !Array.isArray(cfg.peers) || cfg.peers.some((p: unknown) => p !== "local" && p !== "pi")) throw new Error("strict execution budgets support local and Pi only; native Codex/Claude calls are not instrumented");
2058
+ if (cfg.kind === "task" && !board.get(cfg.taskId)) throw new Error("task not found");
2059
+ return void reply({ ok: true, budget: tasks.configureExecutionBudget(cfg) });
2060
+ }
2061
+ if (msg.op === "disable" && typeof msg.id === "string") return void reply({ ok: true, disabled: tasks.disableExecutionBudget(msg.id) });
2062
+ return void reply({ ok: false, error: "unknown execution budget operation" });
2063
+ } catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
2064
+ }
1736
2065
  case "recovery":
1737
2066
  if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
1738
2067
  void recoveryOp(msg).then(reply, (error) => reply(recoveryError((error as Error)?.message ?? "recovery operation failed")));
@@ -1748,11 +2077,84 @@ export async function startDaemon(opts: DaemonOptions) {
1748
2077
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
1749
2078
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
1750
2079
  const env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
1751
- const refused = c.peer ? admit(env, inReplyTo?.id) : undefined;
2080
+ // A silent cohort (issue #107): the members it is held back from are left out, the rest get it; the sender is
2081
+ // told. Held back from everyone, it costs the sender nothing against its limits.
2082
+ const hushed = bus.hushed(env);
2083
+ const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
2084
+ const refused = c.peer && !heldBack ? admit(env, inReplyTo?.id) : undefined;
1752
2085
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
1753
2086
  const targets = bus.publish(env);
1754
- if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id);
1755
- return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
2087
+ if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
2088
+ if (hushed.length && !targets.length) return void reply({ t: "sent", ok: false, error: `not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}` });
2089
+ return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi", ...(hushed.length ? { hushed: hushed.map((h) => h.peer), notice: `not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}` } : {}) });
2090
+ }
2091
+ case "facts": {
2092
+ // Turn-free facts (issue #108) for Claude's hooks: `pre` before a tool runs (the fact rides back as additional
2093
+ // context), `post` after it (its effect, and the readback of what went in before), `stop` when its turn ends.
2094
+ // The hook never shows an error: on any failure the answer is just empty.
2095
+ if (!c.peer || c.role === "console") return void reply({ t: "facts", ok: false, error: "facts are for a peer" });
2096
+ const phase: "pre" | "post" | "stop" = msg.phase === "post" || msg.phase === "stop" ? msg.phase : "pre";
2097
+ const tool = typeof msg.tool === "string" ? msg.tool.slice(0, 64) : "";
2098
+ const input = msg.input && typeof msg.input === "object" && !Array.isArray(msg.input) ? msg.input : {};
2099
+ const toolUseId = typeof msg.toolUseId === "string" && msg.toolUseId ? msg.toolUseId.slice(0, 128) : undefined;
2100
+ const sessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId.slice(0, 128) : undefined;
2101
+ const callStarted = performance.now();
2102
+ const tally = (peer: PeerId) => {
2103
+ const st = hookStats.get(peer) ?? { n: 0, startupMs: 0, hubMs: 0, maxStartupMs: 0 };
2104
+ const startup = typeof msg.startedMs === "number" && Number.isFinite(msg.startedMs) ? Math.max(0, msg.startedMs) : 0;
2105
+ hookStats.set(peer, { n: st.n + 1, startupMs: st.startupMs + startup, hubMs: st.hubMs + (performance.now() - callStarted), maxStartupMs: Math.max(st.maxStartupMs, startup) });
2106
+ };
2107
+ try {
2108
+ const peer = c.peer;
2109
+ // Quiescence evidence is kept in every regime: a PII window must not make an active peer look stopped. Only
2110
+ // a tool call starting is new activity: a PostToolUse of an earlier call can arrive after the Stop.
2111
+ if (phase === "stop") {
2112
+ turnEnded.set(peer, Date.now());
2113
+ tasks.cohorts.turnEnded(peer);
2114
+ event({ type: "native_turn_end", peer });
2115
+ tally(peer);
2116
+ const st = hookStats.get(peer)!;
2117
+ event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
2118
+ hookStats.delete(peer);
2119
+ } else {
2120
+ if (phase === "pre") activeAt.set(peer, Date.now());
2121
+ queueMicrotask(() => tally(peer)); // after this call's own work below
2122
+ }
2123
+ if (!factsOn()) return void reply({ t: "facts", ok: true });
2124
+ factSession(peer, sessionId);
2125
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath);
2126
+ if (phase === "stop") {
2127
+ readbacks(peer, transcript);
2128
+ return void reply({ t: "facts", ok: true });
2129
+ }
2130
+ if (phase === "post") {
2131
+ facts.postTool(peer, toolUseId, tool, input);
2132
+ readbacks(peer, transcript);
2133
+ return void reply({ t: "facts", ok: true });
2134
+ }
2135
+ readbacks(peer, transcript);
2136
+ checkCapability(peer);
2137
+ const started = performance.now();
2138
+ facts.preTool(peer, toolUseId, tool, input);
2139
+ // Without a transcript nothing offered could ever be read back: a verified path is lost, and nothing is offered
2140
+ // (the same diff would otherwise go in at every tool call).
2141
+ if (!transcript) {
2142
+ if (capable.delete(peer)) loseCapability(peer, "its transcript cannot be found");
2143
+ return void reply({ t: "facts", ok: true });
2144
+ }
2145
+ const offered = offerFor(peer, toolUseId);
2146
+ if (offered) event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, bytes: offered.bytes, via: "hook", ms: Math.round(performance.now() - started), ...(typeof msg.startedMs === "number" && Number.isFinite(msg.startedMs) ? { hookMs: Math.round(msg.startedMs) } : {}), ...(offered.probe ? { probe: true } : {}), ...(offered.coverage ? { coverage: true } : {}) });
2147
+ return void reply({ t: "facts", ok: true, ...(offered ? { text: offered.text, id: offered.id } : {}) });
2148
+ } catch (error) {
2149
+ log(`facts for ${c.peer}: ${(error as Error).message}`);
2150
+ return void reply({ t: "facts", ok: true });
2151
+ }
2152
+ }
2153
+ case "silenced": {
2154
+ // `ahub check-path` (issue #107): which of these owners the asking peer shares a silent cohort with.
2155
+ if (!c.peer) return void reply({ t: "silenced", ok: false, error: "silenced is for a peer" });
2156
+ const owners = Array.isArray(msg.owners) ? msg.owners.filter((o: unknown): o is string => typeof o === "string").slice(0, 20) : [];
2157
+ return void reply({ t: "silenced", ok: true, owners: owners.filter((o: string) => !!tasks.silenced(c.peer!, o)) });
1756
2158
  }
1757
2159
  case "tail":
1758
2160
  if (c.role !== "console" || c.tail) return;
@@ -1899,7 +2301,9 @@ export async function startDaemon(opts: DaemonOptions) {
1899
2301
  await modelRelay?.close();
1900
2302
  await egress?.close();
1901
2303
  await piReceipts?.close();
2304
+ collectClaudeUsage();
1902
2305
  budget.close();
2306
+ executionBudget.close();
1903
2307
  board.close();
1904
2308
  turnLog?.close();
1905
2309
  server.stop(true);