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