@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/CHANGELOG.md +15 -0
- package/docs/cooperbench.md +35 -2
- package/docs/events.md +9 -0
- package/docs/operations.md +173 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -1
- package/docs/verification/2026-10-02-0.12.4.md +82 -0
- package/docs/verification/2026-10-03-0.12.5.md +46 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5 -4
- package/src/adapters/claude-channel.ts +2 -1
- package/src/adapters/codex-appserver.ts +51 -6
- package/src/cli/facts-hook.ts +40 -0
- package/src/cli/launch.ts +27 -3
- package/src/cli/main.ts +31 -2
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +75 -3
- package/src/hub/child-process.ts +223 -2
- package/src/hub/cohorts.ts +308 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +389 -15
- package/src/hub/events.ts +9 -0
- package/src/hub/facts.ts +853 -0
- package/src/hub/report.ts +3 -0
- package/src/hub/routing.ts +94 -0
- package/src/hub/tasks.ts +412 -20
- package/templates/AGENTS.block.md +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
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:
|
|
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 }
|