@staix/agent-hub 0.12.3 → 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
@@ -28,7 +28,9 @@ import { Board, CLASSES, type Task, type TaskClass } from "./board.ts";
28
28
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
29
29
  import { HUB } from "./envelope.ts";
30
30
  import { trimToTokens } from "../memory/recall.ts";
31
- 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";
32
34
  import type { BusEvent } from "./bus.ts";
33
35
  import { DEFAULT_ROLES, roleContract, TASK_TOOLS } from "./hub-tools.ts";
34
36
  import { Tasks } from "./tasks.ts";
@@ -45,6 +47,7 @@ import { MemoryClient, workerUrl } from "../memory/client.ts";
45
47
  import { VERSION } from "../version.ts";
46
48
  import { projectChain, recallFor } from "../memory/recall.ts";
47
49
  import { conflictsOf } from "./conflicts.ts";
50
+ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
48
51
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
49
52
  import type { JournalDelivery } from "./delivery-journal.ts";
50
53
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
@@ -83,6 +86,13 @@ export interface HubConfig {
83
86
  recovery: { auto_resume_after_crash: boolean };
84
87
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
85
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" };
86
96
  /** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
87
97
  ignored?: string[];
88
98
  /** Settings that were removed or are about to be (issue #83): what each does now, for `hub.log` and `ahub doctor`. */
@@ -113,6 +123,7 @@ export const DEFAULT_CONFIG: HubConfig = {
113
123
  review: { adaptive: false, min_reviews: 5 },
114
124
  recovery: { auto_resume_after_crash: false },
115
125
  capabilities: {},
126
+ coordination: "advisory",
116
127
  };
117
128
 
118
129
  export { stateDirFor };
@@ -350,6 +361,25 @@ export async function startDaemon(opts: DaemonOptions) {
350
361
  }
351
362
  // Agents only: the console user and the hub itself are never limited (issue #38).
352
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
+ };
353
383
  for (const k of Object.keys(config.limits)) if (!(k in PROJECT_LIMITS)) log(`limits.${k} is not a known limit; ignored`);
354
384
  const limits = Object.fromEntries(Object.entries(PROJECT_LIMITS).map(([k, fallback]) => {
355
385
  const v = config.limits[k as keyof LimitsConfig];
@@ -379,7 +409,13 @@ export async function startDaemon(opts: DaemonOptions) {
379
409
  if (refused) log(`limits: ${env.from}: ${refused}`);
380
410
  return refused;
381
411
  };
382
- 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 });
383
419
  startupCleanup.push(() => bus.closeJournal());
384
420
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
385
421
  let recoveryOperationId: string | undefined;
@@ -464,7 +500,7 @@ export async function startDaemon(opts: DaemonOptions) {
464
500
  const runningChecks = new Set<() => void>();
465
501
  let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
466
502
  const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
467
- const tasks = new Tasks({
503
+ const tasks: Tasks = new Tasks({
468
504
  board,
469
505
  bus,
470
506
  executionBudget,
@@ -495,10 +531,170 @@ export async function startDaemon(opts: DaemonOptions) {
495
531
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
496
532
  review: config.review,
497
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) }),
498
551
  failing: () => bus.failingPeers(),
499
552
  held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
500
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"}`;
501
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
+ }
502
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) });
503
699
  // Models can self-claim after their turn begins; preserve that ownership even if they finish before settlement.
504
700
  const turn = t.owner ? turns.get(t.owner) : undefined;
@@ -607,6 +803,8 @@ export async function startDaemon(opts: DaemonOptions) {
607
803
  let claudeUsageSeen = 0;
608
804
  const intervals = [
609
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),
610
808
  setInterval(() => {
611
809
  try {
612
810
  const file = join(opts.stateDir, "claude-usage.json");
@@ -690,18 +888,27 @@ export async function startDaemon(opts: DaemonOptions) {
690
888
  case "hub_task_propose": {
691
889
  const t = await tasks.propose(by, a);
692
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
693
892
  return overlap ? `${line(t)}\n${overlap}` : line(t);
694
893
  }
695
894
  case "hub_task_accept": {
696
895
  const t = tasks.accept(by, a.id, a.plan);
697
- 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
698
900
  return overlap ? `${line(t)}\n${overlap}` : line(t);
699
901
  }
700
902
  case "hub_task_decline":
701
903
  return line(await tasks.decline(by, a.id, a.reason));
702
904
  case "hub_task_done": {
703
905
  const t = await tasks.done(by, a.id, a.summary, a.refs);
704
- 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;
705
912
  }
706
913
  case "hub_review":
707
914
  return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet));
@@ -927,7 +1134,6 @@ export async function startDaemon(opts: DaemonOptions) {
927
1134
 
928
1135
  // When each peer went offline; a peer never seen attached counts from hub start (issue #6).
929
1136
  const offlineSince = new Map<PeerId, number>();
930
- const hubStartedAt = Date.now();
931
1137
  const releaseGoneOwners = async () => {
932
1138
  const limit = config.tasks.release_after_min;
933
1139
  if (releasing || stopping || recoveryActive()) return;
@@ -958,6 +1164,8 @@ export async function startDaemon(opts: DaemonOptions) {
958
1164
  // before it. Warns both owners once per file and task; never blocks a write.
959
1165
  const conflictSeen = new Set<string>();
960
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"];
961
1169
  const detectConcurrentConflicts = (record: TurnRecord, mine: Task[]) => {
962
1170
  for (const otherTurn of turnLog!.concurrentWith(record)) {
963
1171
  const otherTasks = (turnTasks.get(otherTurn.id) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
@@ -972,15 +1180,17 @@ export async function startDaemon(opts: DaemonOptions) {
972
1180
  for (const path of paths) conflictSeen.add(`concurrent:${pair}:${path}`);
973
1181
  const named = paths.filter(tasks.nameable);
974
1182
  const files = [...named, ...(paths.length > named.length ? [`${paths.length - named.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
975
- 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}`;
976
1185
  notify(`conflict: ${text}`);
977
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] });
978
- [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);
979
1189
  }
980
1190
  }
981
1191
  };
982
1192
  const detectConflicts = (peer: PeerId, turnId: string, since: number, changed: string[]) => {
983
- 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));
984
1194
  const mine = (turnTasks.get(turnId) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
985
1195
  if (mine.some((t) => tasks.isPii(t))) return; // a PII turn's files are nobody else's business
986
1196
  const visible = open.filter((t) => !tasks.isPii(t));
@@ -1010,8 +1220,12 @@ export async function startDaemon(opts: DaemonOptions) {
1010
1220
  const files = [...named, ...(hidden ? [`${hidden} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
1011
1221
  notify(`conflict: ${peer}${ours} changed ${files}, which #${task.id} (owner ${owner}) changed before${others.length ? ` (concurrent: ${others.join(", ")})` : ""}`);
1012
1222
  event({ type: "conflict", peer, ...(mine[0] ? { task: mine[0].id } : {}), other: task.id, owner, paths: named, concurrent: others.length > 0 });
1013
- 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) } } : {}) }));
1014
- 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);
1015
1229
  }
1016
1230
  };
1017
1231
 
@@ -1096,10 +1310,20 @@ export async function startDaemon(opts: DaemonOptions) {
1096
1310
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
1097
1311
  }
1098
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
+ }
1099
1317
  try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
1100
1318
  }
1101
1319
  if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
1102
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
+ }
1103
1327
  // After a crash, a peer's first attach brings the loss notice: it leads its next delivery (issue #37).
1104
1328
  if (e.state !== "offline" && lost.has(e.peer)) {
1105
1329
  const still = lost.get(e.peer)!.filter((d) => { try { return journal.get(d.id)?.state === "needs_review"; } catch { return false; } });
@@ -1108,6 +1332,14 @@ export async function startDaemon(opts: DaemonOptions) {
1108
1332
  }
1109
1333
  recordSessions();
1110
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
+ }
1111
1343
  else if (e.t === "undeliverable" || e.t === "overflow") {
1112
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}`);
1113
1345
  event({ type: e.t, id: e.env.id, from: e.env.from, peer: e.peer });
@@ -1288,11 +1520,40 @@ export async function startDaemon(opts: DaemonOptions) {
1288
1520
  return { ok: true };
1289
1521
  }
1290
1522
  if (peer === "codex") {
1523
+ let codexSteering = false;
1291
1524
  const codex = new CodexPeer("codex", {
1292
1525
  onTokens: (added) => void addTokens("codex", added),
1293
1526
  onTurn: (native) => {
1294
1527
  const open = turns.get("codex");
1295
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
+ });
1296
1557
  },
1297
1558
  appPort: opts.codexAppPort,
1298
1559
  proxyPort: opts.codexProxyPort,
@@ -1816,11 +2077,84 @@ export async function startDaemon(opts: DaemonOptions) {
1816
2077
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
1817
2078
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
1818
2079
  const env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
1819
- 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;
1820
2085
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
1821
2086
  const targets = bus.publish(env);
1822
- if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id);
1823
- 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)) });
1824
2158
  }
1825
2159
  case "tail":
1826
2160
  if (c.role !== "console" || c.tail) return;
package/src/hub/events.ts CHANGED
@@ -10,9 +10,18 @@ export const EVENTS_SCHEMA = 1;
10
10
  export type HubEvent =
11
11
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
12
12
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
13
+ | { type: "stale"; id: string; from: string; peer: string; task?: string }
14
+ | { type: "quiet"; id: string; from: string; peers: string[] }
15
+ | { type: "fact"; peer: string; id: string; files: number; plans: number; unknown: number; bytes: number; via: "hook" | "steer" | "done"; ms?: number; hookMs?: number; rttMs?: number; accepted?: boolean; unanswered?: boolean; probe?: boolean; coverage?: boolean }
16
+ | { type: "fact_ack"; peer: string; id: string; via: string; ms: number }
17
+ | { type: "capability"; peer: string; state: "verified" | "lost"; via?: string }
18
+ | { type: "split"; task: number; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
13
19
  | { type: "state"; peer: string; state: string }
14
20
  | { type: "turn_start"; peer: string; turn: string }
15
21
  | { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
22
+ | { type: "native_turn_end"; peer: string }
23
+ | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
24
+ | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
16
25
  | { type: "tokens"; peer: string; n: number }
17
26
  | { type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number }
18
27
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }