@staix/agent-hub 0.12.3 → 0.12.5

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
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { spawn } from "node:child_process";
@@ -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, constants as fsConstants, 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, named: offered.named, 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
+ splitProfile: (peer) => splitProfile(peer),
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, where) => event({ type: "split", task, where, 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));
@@ -811,6 +1018,46 @@ export async function startDaemon(opts: DaemonOptions) {
811
1018
  };
812
1019
  } catch { return {}; }
813
1020
  };
1021
+ /**
1022
+ * Claude Code's version, from the last row of its transcript that names one (its rows carry it): the last MiB is read,
1023
+ * opened without blocking and only as a regular file, and the last version found is kept for a tail of big rows.
1024
+ */
1025
+ const claudeVersions = new Map<string, string>();
1026
+ const claudeVersion = (): string | undefined => {
1027
+ const path = claudeSession().transcriptPath;
1028
+ if (!path) return undefined;
1029
+ let fd: number | undefined;
1030
+ try {
1031
+ fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
1032
+ const st = fstatSync(fd);
1033
+ if (!st.isFile()) return claudeVersions.get(path);
1034
+ const buf = Buffer.alloc(Math.min(st.size, 1024 * 1024));
1035
+ readSync(fd, buf, 0, buf.length, st.size - buf.length);
1036
+ for (const line of buf.toString("utf8").split("\n").reverse()) {
1037
+ try {
1038
+ const version = JSON.parse(line)?.version;
1039
+ const found = typeof version === "string" ? /^\d+\.\d+\.\d+(?:-[\w.]+)?/.exec(version)?.[0] : undefined;
1040
+ if (found) {
1041
+ claudeVersions.set(path, found);
1042
+ return found;
1043
+ }
1044
+ } catch { /* the cut first line, or not JSON */ }
1045
+ }
1046
+ } catch { /* no transcript yet */ } finally { if (fd !== undefined) closeSync(fd); }
1047
+ return claudeVersions.get(path);
1048
+ };
1049
+ /**
1050
+ * A peer's split profile (issue #109): the hub's version, its agent's, and the hook profile, which is the hub's own (a
1051
+ * turn-free project runs the facts hooks in Claude and steers facts into Codex). Undefined while a version is unknown.
1052
+ * ponytail: the user's and plugins' hooks are not seen here, and only Claude and Codex report a version; read hooks
1053
+ * from the native records, as the benchmark ledger does, and add other agents' versions when a pair needs them.
1054
+ */
1055
+ function splitProfile(peer: PeerId): string | undefined {
1056
+ try {
1057
+ const version = peer === "codex" ? (bus.peers.get("codex") as { version?: string } | undefined)?.version : peer === "claude" ? claudeVersion() : undefined;
1058
+ return version ? `hub ${VERSION}; ${peer} ${version}; ${turnFree() ? "turn-free" : "advisory"}` : undefined; // the regime in force: a PII task suspends turn-free
1059
+ } catch { return undefined; } // asked before the daemon finished starting
1060
+ }
814
1061
  /** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
815
1062
  * path Claude itself reported through the status line; fall back to the slug computation. */
816
1063
  const claudeTranscriptPersisted = (sessionId: string): boolean => {
@@ -927,7 +1174,6 @@ export async function startDaemon(opts: DaemonOptions) {
927
1174
 
928
1175
  // When each peer went offline; a peer never seen attached counts from hub start (issue #6).
929
1176
  const offlineSince = new Map<PeerId, number>();
930
- const hubStartedAt = Date.now();
931
1177
  const releaseGoneOwners = async () => {
932
1178
  const limit = config.tasks.release_after_min;
933
1179
  if (releasing || stopping || recoveryActive()) return;
@@ -958,6 +1204,8 @@ export async function startDaemon(opts: DaemonOptions) {
958
1204
  // before it. Warns both owners once per file and task; never blocks a write.
959
1205
  const conflictSeen = new Set<string>();
960
1206
  const turnTasks = new Map<string, number[]>();
1207
+ /** Work a conflict notice is about: open tasks and tasks in review (#91); a notice for anything else is dropped (#106). */
1208
+ const CONFLICT_STATES: Task["state"][] = ["proposed", "in_progress", "changes_requested", "in_review"];
961
1209
  const detectConcurrentConflicts = (record: TurnRecord, mine: Task[]) => {
962
1210
  for (const otherTurn of turnLog!.concurrentWith(record)) {
963
1211
  const otherTasks = (turnTasks.get(otherTurn.id) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
@@ -972,15 +1220,17 @@ export async function startDaemon(opts: DaemonOptions) {
972
1220
  for (const path of paths) conflictSeen.add(`concurrent:${pair}:${path}`);
973
1221
  const named = paths.filter(tasks.nameable);
974
1222
  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.`;
1223
+ 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.";
1224
+ 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
1225
  notify(`conflict: ${text}`);
977
1226
  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" })));
1227
+ // Each owner hears it while its own task is open; once that task is closed the notice is dropped (issue #106).
1228
+ 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
1229
  }
980
1230
  }
981
1231
  };
982
1232
  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));
1233
+ const open = board.list().filter((t) => t.owner && CONFLICT_STATES.includes(t.state));
984
1234
  const mine = (turnTasks.get(turnId) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
985
1235
  if (mine.some((t) => tasks.isPii(t))) return; // a PII turn's files are nobody else's business
986
1236
  const visible = open.filter((t) => !tasks.isPii(t));
@@ -1010,8 +1260,12 @@ export async function startDaemon(opts: DaemonOptions) {
1010
1260
  const files = [...named, ...(hidden ? [`${hidden} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
1011
1261
  notify(`conflict: ${peer}${ours} changed ${files}, which #${task.id} (owner ${owner}) changed before${others.length ? ` (concurrent: ${others.join(", ")})` : ""}`);
1012
1262
  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) } }));
1263
+ // Both notices are about an open task of their recipient: dropped at delivery once it is closed (issue #106).
1264
+ 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`;
1265
+ 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}`;
1266
+ if (mine[0]) tasks.whileOpen(peer, mine[0].id, toPeer, CONFLICT_STATES);
1267
+ else bus.publish(newEnvelope(HUB, toPeer, { to: [peer], kind: "task" }));
1268
+ 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
1269
  }
1016
1270
  };
1017
1271
 
@@ -1096,10 +1350,20 @@ export async function startDaemon(opts: DaemonOptions) {
1096
1350
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
1097
1351
  }
1098
1352
  event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
1353
+ if (e.peer !== "claude") { // Claude's native turn end is its Stop hook
1354
+ turnEnded.set(e.peer, Date.now());
1355
+ tasks.cohorts.turnEnded(e.peer);
1356
+ }
1099
1357
  try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
1100
1358
  }
1101
1359
  if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
1102
1360
  else offlineSince.delete(e.peer);
1361
+ // A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
1362
+ if (e.state === "offline" && capable.delete(e.peer)) loseCapability(e.peer, "it went offline");
1363
+ if (e.state !== "offline" && coordination === "turn-free" && !replayed.has(e.peer)) {
1364
+ replayed.add(e.peer);
1365
+ tasks.replayHeld(e.peer);
1366
+ }
1103
1367
  // After a crash, a peer's first attach brings the loss notice: it leads its next delivery (issue #37).
1104
1368
  if (e.state !== "offline" && lost.has(e.peer)) {
1105
1369
  const still = lost.get(e.peer)!.filter((d) => { try { return journal.get(d.id)?.state === "needs_review"; } catch { return false; } });
@@ -1108,6 +1372,14 @@ export async function startDaemon(opts: DaemonOptions) {
1108
1372
  }
1109
1373
  recordSessions();
1110
1374
  }
1375
+ else if (e.t === "stale") {
1376
+ log(`STALE ${e.peer}: dropped ${e.env.id} from ${e.env.from}: ${e.reason}`);
1377
+ event({ type: "stale", id: e.env.id, from: e.env.from, peer: e.peer, ...(e.env.refs?.task ? { task: e.env.refs.task } : {}) });
1378
+ }
1379
+ else if (e.t === "quiet") {
1380
+ log(`QUIET ${e.env.id} from ${e.env.from}: not delivered to ${e.peers.join(", ")} (turn-free cohort)`);
1381
+ event({ type: "quiet", id: e.env.id, from: e.env.from, peers: e.peers });
1382
+ }
1111
1383
  else if (e.t === "undeliverable" || e.t === "overflow") {
1112
1384
  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
1385
  event({ type: e.t, id: e.env.id, from: e.env.from, peer: e.peer });
@@ -1288,11 +1560,40 @@ export async function startDaemon(opts: DaemonOptions) {
1288
1560
  return { ok: true };
1289
1561
  }
1290
1562
  if (peer === "codex") {
1563
+ let codexSteering = false;
1291
1564
  const codex = new CodexPeer("codex", {
1292
1565
  onTokens: (added) => void addTokens("codex", added),
1293
1566
  onTurn: (native) => {
1294
1567
  const open = turns.get("codex");
1295
1568
  if (open) turnLog?.native(open.id, native);
1569
+ if (factsOn()) factSession("codex", codex.thread); // a new thread is unverified before its first item
1570
+ },
1571
+ // Turn-free facts (issue #108): its items are the boundaries; a fact goes into the running turn by steer, and
1572
+ // the steered input coming back as a user message item is its readback.
1573
+ onItem: (item) => {
1574
+ if (!factsOn()) return;
1575
+ factSession("codex", codex.thread);
1576
+ if (item.type === "userMessage") {
1577
+ const text = JSON.stringify(item.content ?? "");
1578
+ if (text.includes(FACTS_PREFIX)) for (const o of facts.pending("codex")) if (text.includes(`[${o.id}]`)) acked("codex", o.id, "steer");
1579
+ return;
1580
+ }
1581
+ facts.codexItem("codex", item);
1582
+ checkCapability("codex");
1583
+ if (codexSteering) return; // one at a time: the next boundary offers what this one could not
1584
+ const started = performance.now();
1585
+ const offered = offerFor("codex");
1586
+ if (!offered) return;
1587
+ const ms = Math.round(performance.now() - started);
1588
+ codexSteering = true;
1589
+ const sent = performance.now();
1590
+ void codex.steerText(offered.text).then((outcome) => {
1591
+ codexSteering = false;
1592
+ // Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
1593
+ // have: its readback can still come.
1594
+ if (outcome === "refused") facts.drop("codex", offered.id);
1595
+ event({ type: "fact", peer: "codex", id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, named: offered.named, 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 } : {}) });
1596
+ });
1296
1597
  },
1297
1598
  appPort: opts.codexAppPort,
1298
1599
  proxyPort: opts.codexProxyPort,
@@ -1816,11 +2117,84 @@ export async function startDaemon(opts: DaemonOptions) {
1816
2117
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
1817
2118
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
1818
2119
  const env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
1819
- const refused = c.peer ? admit(env, inReplyTo?.id) : undefined;
2120
+ // A silent cohort (issue #107): the members it is held back from are left out, the rest get it; the sender is
2121
+ // told. Held back from everyone, it costs the sender nothing against its limits.
2122
+ const hushed = bus.hushed(env);
2123
+ const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
2124
+ const refused = c.peer && !heldBack ? admit(env, inReplyTo?.id) : undefined;
1820
2125
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
1821
2126
  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" });
2127
+ if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
2128
+ 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}` });
2129
+ 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}` } : {}) });
2130
+ }
2131
+ case "facts": {
2132
+ // Turn-free facts (issue #108) for Claude's hooks: `pre` before a tool runs (the fact rides back as additional
2133
+ // context), `post` after it (its effect, and the readback of what went in before), `stop` when its turn ends.
2134
+ // The hook never shows an error: on any failure the answer is just empty.
2135
+ if (!c.peer || c.role === "console") return void reply({ t: "facts", ok: false, error: "facts are for a peer" });
2136
+ const phase: "pre" | "post" | "stop" = msg.phase === "post" || msg.phase === "stop" ? msg.phase : "pre";
2137
+ const tool = typeof msg.tool === "string" ? msg.tool.slice(0, 64) : "";
2138
+ const input = msg.input && typeof msg.input === "object" && !Array.isArray(msg.input) ? msg.input : {};
2139
+ const toolUseId = typeof msg.toolUseId === "string" && msg.toolUseId ? msg.toolUseId.slice(0, 128) : undefined;
2140
+ const sessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId.slice(0, 128) : undefined;
2141
+ const callStarted = performance.now();
2142
+ const tally = (peer: PeerId) => {
2143
+ const st = hookStats.get(peer) ?? { n: 0, startupMs: 0, hubMs: 0, maxStartupMs: 0 };
2144
+ const startup = typeof msg.startedMs === "number" && Number.isFinite(msg.startedMs) ? Math.max(0, msg.startedMs) : 0;
2145
+ hookStats.set(peer, { n: st.n + 1, startupMs: st.startupMs + startup, hubMs: st.hubMs + (performance.now() - callStarted), maxStartupMs: Math.max(st.maxStartupMs, startup) });
2146
+ };
2147
+ try {
2148
+ const peer = c.peer;
2149
+ // Quiescence evidence is kept in every regime: a PII window must not make an active peer look stopped. Only
2150
+ // a tool call starting is new activity: a PostToolUse of an earlier call can arrive after the Stop.
2151
+ if (phase === "stop") {
2152
+ turnEnded.set(peer, Date.now());
2153
+ tasks.cohorts.turnEnded(peer);
2154
+ event({ type: "native_turn_end", peer });
2155
+ tally(peer);
2156
+ const st = hookStats.get(peer)!;
2157
+ event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
2158
+ hookStats.delete(peer);
2159
+ } else {
2160
+ if (phase === "pre") activeAt.set(peer, Date.now());
2161
+ queueMicrotask(() => tally(peer)); // after this call's own work below
2162
+ }
2163
+ if (!factsOn()) return void reply({ t: "facts", ok: true });
2164
+ factSession(peer, sessionId);
2165
+ const transcript = claudeTranscript(sessionId, msg.transcriptPath);
2166
+ if (phase === "stop") {
2167
+ readbacks(peer, transcript);
2168
+ return void reply({ t: "facts", ok: true });
2169
+ }
2170
+ if (phase === "post") {
2171
+ facts.postTool(peer, toolUseId, tool, input);
2172
+ readbacks(peer, transcript);
2173
+ return void reply({ t: "facts", ok: true });
2174
+ }
2175
+ readbacks(peer, transcript);
2176
+ checkCapability(peer);
2177
+ const started = performance.now();
2178
+ facts.preTool(peer, toolUseId, tool, input);
2179
+ // Without a transcript nothing offered could ever be read back: a verified path is lost, and nothing is offered
2180
+ // (the same diff would otherwise go in at every tool call).
2181
+ if (!transcript) {
2182
+ if (capable.delete(peer)) loseCapability(peer, "its transcript cannot be found");
2183
+ return void reply({ t: "facts", ok: true });
2184
+ }
2185
+ const offered = offerFor(peer, toolUseId);
2186
+ if (offered) event({ type: "fact", peer, id: offered.id, files: offered.files, plans: offered.plans, unknown: offered.unknown, named: offered.named, 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 } : {}) });
2187
+ return void reply({ t: "facts", ok: true, ...(offered ? { text: offered.text, id: offered.id } : {}) });
2188
+ } catch (error) {
2189
+ log(`facts for ${c.peer}: ${(error as Error).message}`);
2190
+ return void reply({ t: "facts", ok: true });
2191
+ }
2192
+ }
2193
+ case "silenced": {
2194
+ // `ahub check-path` (issue #107): which of these owners the asking peer shares a silent cohort with.
2195
+ if (!c.peer) return void reply({ t: "silenced", ok: false, error: "silenced is for a peer" });
2196
+ const owners = Array.isArray(msg.owners) ? msg.owners.filter((o: unknown): o is string => typeof o === "string").slice(0, 20) : [];
2197
+ return void reply({ t: "silenced", ok: true, owners: owners.filter((o: string) => !!tasks.silenced(c.peer!, o)) });
1824
2198
  }
1825
2199
  case "tail":
1826
2200
  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; named: 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; where?: "routing" | "cohort"; 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 }