@staix/agent-hub 0.12.4 → 0.12.6
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 +22 -0
- package/docs/cooperbench.md +7 -5
- package/docs/events.md +2 -2
- package/docs/operations.md +40 -20
- package/docs/specs/2026-09-19-agent-hub-design.md +4 -3
- package/docs/verification/2026-10-03-0.12.5.md +46 -0
- package/docs/verification/2026-10-03-0.12.6.md +163 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/acp.ts +7 -5
- package/src/adapters/codex-appserver.ts +19 -10
- package/src/adapters/pi.ts +9 -4
- package/src/hub/board.ts +4 -2
- package/src/hub/child-process.ts +241 -2
- package/src/hub/daemon.ts +54 -8
- package/src/hub/events.ts +2 -2
- package/src/hub/facts.ts +128 -29
- package/src/hub/routing.ts +11 -2
- package/src/hub/tasks.ts +76 -26
|
@@ -15727,7 +15727,7 @@ class ControlClient {
|
|
|
15727
15727
|
// package.json
|
|
15728
15728
|
var package_default = {
|
|
15729
15729
|
name: "@staix/agent-hub",
|
|
15730
|
-
version: "0.12.
|
|
15730
|
+
version: "0.12.6",
|
|
15731
15731
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15732
15732
|
license: "MIT",
|
|
15733
15733
|
type: "module",
|
package/src/adapters/acp.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
|
-
import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
|
|
5
|
+
import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
6
|
|
|
7
7
|
export interface PermissionOption {
|
|
8
8
|
optionId: string;
|
|
@@ -85,8 +85,10 @@ export class AcpPeer extends BasePeer {
|
|
|
85
85
|
|
|
86
86
|
async start(): Promise<void> {
|
|
87
87
|
const [bin, ...args] = this.opts.cmd;
|
|
88
|
-
|
|
88
|
+
// Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
|
|
89
|
+
const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
89
90
|
this.proc = proc;
|
|
91
|
+
trackGroup(proc);
|
|
90
92
|
proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
|
|
91
93
|
proc.on("exit", (code) => this.down(`exited with code ${code}`));
|
|
92
94
|
proc.stdin.on("error", () => {}); // EPIPE from a child that died; `exit` / `error` already report it
|
|
@@ -112,7 +114,7 @@ export class AcpPeer extends BasePeer {
|
|
|
112
114
|
try {
|
|
113
115
|
this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
|
|
114
116
|
} catch (e) {
|
|
115
|
-
await stopOwnedProcess(proc);
|
|
117
|
+
await stopOwnedProcess(proc, { group: true }).catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
|
|
116
118
|
throw e;
|
|
117
119
|
}
|
|
118
120
|
this.setState("idle");
|
|
@@ -122,8 +124,8 @@ export class AcpPeer extends BasePeer {
|
|
|
122
124
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
|
|
123
125
|
this.activeDeliveryId = undefined;
|
|
124
126
|
const proc = this.proc;
|
|
125
|
-
if (!proc
|
|
126
|
-
await stopOwnedProcess(proc);
|
|
127
|
+
if (!proc) return;
|
|
128
|
+
await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
|
|
127
129
|
if (this.proc === proc) this.proc = undefined;
|
|
128
130
|
}
|
|
129
131
|
|
|
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
|
-
import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
|
|
5
|
+
import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
6
|
|
|
7
7
|
export interface CodexOptions {
|
|
8
8
|
/** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
|
|
@@ -79,6 +79,8 @@ export class CodexPeer extends BasePeer {
|
|
|
79
79
|
private lastAnswer = "";
|
|
80
80
|
private readonly deltas = new Map<string, string[]>();
|
|
81
81
|
private primed = false;
|
|
82
|
+
/** The Codex version app-server reported in its `initialize` answer (`<client>/<version> (...)`), once seen. */
|
|
83
|
+
version: string | undefined;
|
|
82
84
|
private readonly steers = new Set<number>(); // request ids of turn/steer calls app-server has not answered yet
|
|
83
85
|
private readonly turnDeliveries = new Map<string, Set<string>>();
|
|
84
86
|
private readonly unboundDeliveries = new Set<string>();
|
|
@@ -137,21 +139,26 @@ export class CodexPeer extends BasePeer {
|
|
|
137
139
|
});
|
|
138
140
|
} catch (error) {
|
|
139
141
|
const proc = this.proc;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (this.proc === proc) this.proc = undefined;
|
|
143
|
-
}
|
|
142
|
+
// The start's own error is the one reported: a stop that fails too is logged beside it.
|
|
143
|
+
if (proc && (await this.stopQuietly(proc)) && this.proc === proc) this.proc = undefined;
|
|
144
144
|
throw error;
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Stops `proc` as a group; a failure is logged and reported as false, for paths that throw an error of their own. */
|
|
149
|
+
private stopQuietly(proc: ChildProcess): Promise<boolean> {
|
|
150
|
+
return stopOwnedProcess(proc, { group: true }).then(() => true, (error: Error) => { this.opts.log?.(`[${this.id}] ${error.message}`); return false; });
|
|
151
|
+
}
|
|
152
|
+
|
|
148
153
|
async stop(): Promise<void> {
|
|
149
154
|
this.server?.stop(true);
|
|
150
155
|
this.claimedTui?.tui.close(1001, "hub shutting down");
|
|
151
156
|
const proc = this.proc;
|
|
152
|
-
if (proc
|
|
153
|
-
// Wait for the port to be released: `ahub codex` may restart the adapter right away.
|
|
154
|
-
|
|
157
|
+
if (proc) {
|
|
158
|
+
// Wait for the port to be released: `ahub codex` may restart the adapter right away. A launcher that already
|
|
159
|
+
// exited still has its pipes dropped there, so nothing it left can keep the hub alive, and a group it left members
|
|
160
|
+
// in fails the stop rather than freeing the port for a restart.
|
|
161
|
+
await stopOwnedProcess(proc, { group: true });
|
|
155
162
|
if (this.proc === proc) this.proc = undefined;
|
|
156
163
|
}
|
|
157
164
|
this.setState("offline");
|
|
@@ -298,7 +305,9 @@ export class CodexPeer extends BasePeer {
|
|
|
298
305
|
cwd: this.opts.cwd,
|
|
299
306
|
env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }),
|
|
300
307
|
stdio: ["ignore", "ignore", "pipe"],
|
|
308
|
+
detached: true, // its own process group, stopped as a whole (#113): `codex` is a launcher with a native child
|
|
301
309
|
});
|
|
310
|
+
trackGroup(this.proc);
|
|
302
311
|
this.proc.stderr?.on("data", (d) => this.opts.log?.(`[${this.id}] ${String(d).trimEnd()}`));
|
|
303
312
|
this.proc.on("error", (e) => (gone = `cannot run ${this.opts.bin ?? "codex"}: ${e.message}`));
|
|
304
313
|
this.proc.on("exit", (code) => {
|
|
@@ -312,8 +321,7 @@ export class CodexPeer extends BasePeer {
|
|
|
312
321
|
}
|
|
313
322
|
if (gone) throw new Error(gone);
|
|
314
323
|
const proc = this.proc;
|
|
315
|
-
await
|
|
316
|
-
if (this.proc === proc) this.proc = undefined;
|
|
324
|
+
if (proc && (await this.stopQuietly(proc)) && this.proc === proc) this.proc = undefined;
|
|
317
325
|
throw new Error("codex app-server did not become healthy within 10 s");
|
|
318
326
|
}
|
|
319
327
|
|
|
@@ -375,6 +383,7 @@ export class CodexPeer extends BasePeer {
|
|
|
375
383
|
else p?.resolve(msg.result);
|
|
376
384
|
return; // ours: the TUI never asked for it
|
|
377
385
|
}
|
|
386
|
+
if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
|
|
378
387
|
const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
|
|
379
388
|
if (tracked && link.tracked.delete(msg.id)) this.adopt(link, msg.result?.thread?.id, tracked === "thread/start");
|
|
380
389
|
else if (msg.method) this.onNotification(link, msg.method, msg.params ?? {});
|
package/src/adapters/pi.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join, resolve, relative } from "node:path";
|
|
|
4
4
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
5
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
6
6
|
import { BasePeer } from "../hub/peers.ts";
|
|
7
|
-
import { stopOwnedProcess } from "../hub/child-process.ts";
|
|
7
|
+
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import { realPath } from "../hub/project.ts";
|
|
9
9
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
10
10
|
|
|
@@ -266,7 +266,9 @@ export class PiPeer extends BasePeer {
|
|
|
266
266
|
const command = this.opts.cmd ?? ["pi"];
|
|
267
267
|
this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
|
|
268
268
|
if (this.opts.mode === "tui") { return; }
|
|
269
|
-
|
|
269
|
+
// Its own process group, stopped as a whole (#115, as Codex's in #113).
|
|
270
|
+
this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
271
|
+
trackGroup(this.proc);
|
|
270
272
|
this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
|
|
271
273
|
this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
|
|
272
274
|
this.proc.on("error", (error) => this.fail(error));
|
|
@@ -311,8 +313,10 @@ export class PiPeer extends BasePeer {
|
|
|
311
313
|
this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
312
314
|
}
|
|
313
315
|
const proc = this.proc;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
// Also when it exited: what it left in its group fails the stop. The rest of the teardown runs either way; a process
|
|
317
|
+
// that could not be stopped stays recorded, so a later stop tries it again.
|
|
318
|
+
let failed: unknown;
|
|
319
|
+
if (proc) await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (error: unknown) => { failed = error; });
|
|
316
320
|
for (const waiter of this.tuiWaiters) { clearTimeout(waiter.timer); waiter.resolve(undefined); }
|
|
317
321
|
this.tuiWaiters = [];
|
|
318
322
|
for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner stopped"));
|
|
@@ -320,6 +324,7 @@ export class PiPeer extends BasePeer {
|
|
|
320
324
|
for (const pending of this.pending.values()) pending.reject(new Error("Pi owner stopped"));
|
|
321
325
|
this.pending.clear();
|
|
322
326
|
this.server?.stop(true); this.server = undefined; this.setState("offline");
|
|
327
|
+
if (failed) throw failed;
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
/**
|
package/src/hub/board.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface HistoryEntry {
|
|
|
24
24
|
note?: string;
|
|
25
25
|
/** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
|
|
26
26
|
owner?: PeerId | null;
|
|
27
|
+
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
28
|
+
profile?: string;
|
|
27
29
|
}
|
|
28
30
|
export interface Task {
|
|
29
31
|
id: number;
|
|
@@ -117,14 +119,14 @@ export class Board {
|
|
|
117
119
|
|
|
118
120
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
119
121
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
120
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string): Task {
|
|
122
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile"> = {}): Task {
|
|
121
123
|
const task = this.get(id);
|
|
122
124
|
if (!task) throw new Error(`no task #${id}`);
|
|
123
125
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
124
126
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
125
127
|
}
|
|
126
128
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
127
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}) }];
|
|
129
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}) }];
|
|
128
130
|
this.db
|
|
129
131
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
130
132
|
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|
package/src/hub/child-process.ts
CHANGED
|
@@ -11,14 +11,122 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
11
11
|
return env;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** One process: its identity is the pid with its start time (`lstart`, which exec keeps); group and command are evidence. */
|
|
15
|
+
export interface ProcRow {
|
|
16
|
+
pid: number;
|
|
17
|
+
ppid: number;
|
|
18
|
+
pgid: number;
|
|
19
|
+
started: string;
|
|
20
|
+
command: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `ps -axo pid=,ppid=,pgid=,stat=,lstart=,command=` output (unlimited width when not on a terminal), read in the C
|
|
25
|
+
* locale. A zombie is left out: it has exited, and only its parent's wait is missing.
|
|
26
|
+
*/
|
|
27
|
+
export function parseProcessTable(text: string): ProcRow[] {
|
|
28
|
+
return text.split("\n").flatMap((line) => {
|
|
29
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3} [ \d]\d \d\d:\d\d:\d\d \d{4})\s+(.*)$/.exec(line);
|
|
30
|
+
return m && !m[4]!.startsWith("Z") ? [{ pid: Number(m[1]), ppid: Number(m[2]), pgid: Number(m[3]), started: m[5]!, command: m[6]! }] : [];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PS = ["ps", "-axo", "pid=,ppid=,pgid=,stat=,lstart=,command="];
|
|
35
|
+
// `lstart` is local time: one zone for every reader, or a reader in another zone sees every identity as changed.
|
|
36
|
+
const PS_OPTIONS = { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
|
|
37
|
+
/** The rows of one read, or undefined when they do not show the reader: a parse that found nothing is not a table. */
|
|
38
|
+
const own = (text: string) => { const rows = parseProcessTable(text); return rows.some((row) => row.pid === process.pid) ? rows : undefined; };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The process table, or undefined when it cannot be read or does not show this process: a parse that found nothing
|
|
42
|
+
* (a localized `lstart`, a changed `ps`) is not an empty table. Read with LC_ALL=C for that reason (and TZ=UTC, so
|
|
43
|
+
* start times compare across readers), by a `ps` in a
|
|
44
|
+
* process group of its own (a Ctrl-C to the caller's group would kill it), bounded, and tried twice.
|
|
45
|
+
*/
|
|
46
|
+
export function processTable(): ProcRow[] | undefined {
|
|
47
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
const r = Bun.spawnSync(PS, { ...PS_OPTIONS, stdout: "pipe", stderr: "pipe", timeout: 10_000 });
|
|
50
|
+
const rows = r.exitCode === 0 ? own(r.stdout.toString()) : undefined;
|
|
51
|
+
if (rows) return rows;
|
|
52
|
+
} catch {
|
|
53
|
+
// tried again below
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `processTable`, without blocking the event loop: a hub stop reads the table many times while it keeps serving. */
|
|
60
|
+
export async function readProcessTable(): Promise<ProcRow[] | undefined> {
|
|
61
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
62
|
+
try {
|
|
63
|
+
const p = Bun.spawn(PS, { ...PS_OPTIONS, stdout: "pipe", stderr: "ignore" });
|
|
64
|
+
const timer = setTimeout(() => p.kill("SIGKILL"), 10_000);
|
|
65
|
+
const [text, code] = await Promise.all([new Response(p.stdout).text(), p.exited]).finally(() => clearTimeout(timer));
|
|
66
|
+
const rows = code === 0 ? own(text) : undefined;
|
|
67
|
+
if (rows) return rows;
|
|
68
|
+
} catch {
|
|
69
|
+
// tried again below
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every descendant of `pid` in `rows`, by parent links. */
|
|
76
|
+
export function descendantsOf(rows: ProcRow[], pid: number): ProcRow[] {
|
|
77
|
+
const out: ProcRow[] = [];
|
|
78
|
+
const parents = new Set([pid]);
|
|
79
|
+
for (let grew = true; grew; ) {
|
|
80
|
+
grew = false;
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
if (parents.has(row.ppid) && !parents.has(row.pid)) {
|
|
83
|
+
parents.add(row.pid);
|
|
84
|
+
out.push(row);
|
|
85
|
+
grew = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
14
92
|
/**
|
|
15
93
|
* Stop a child owned by this hub and confirm that it exited. A timeout is an
|
|
16
94
|
* incomplete shutdown, even after SIGKILL, because callers must not reuse a
|
|
17
95
|
* port or claim ownership while the old process may still be alive.
|
|
96
|
+
*
|
|
97
|
+
* `group`: the child was spawned `detached` and leads its own process group; see `stopGroup`. `table` replaces the
|
|
98
|
+
* process table read (tests).
|
|
18
99
|
*/
|
|
19
|
-
export async function stopOwnedProcess(proc: ChildProcess, termMs = 1_000, killMs = 2_000): Promise<void> {
|
|
20
|
-
if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined)
|
|
100
|
+
export async function stopOwnedProcess(proc: ChildProcess, { termMs = 1_000, killMs = 2_000, group = false, table = readProcessTable }: { termMs?: number; killMs?: number; group?: boolean; table?: () => ProcRow[] | undefined | Promise<ProcRow[] | undefined> } = {}): Promise<void> {
|
|
101
|
+
if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) {
|
|
102
|
+
// ponytail: a leader that exited before the stop (its launcher killed from outside) is not swept, as its pid may be
|
|
103
|
+
// reused once its group empties; its pipes are dropped so a survivor cannot keep the hub alive, and a group that
|
|
104
|
+
// still has members fails the stop instead of reading as done. Record the leader's start time at spawn to sweep it.
|
|
105
|
+
if (proc.pid === undefined) return;
|
|
106
|
+
dropPipes(proc); // whatever it left cannot keep the hub alive through them
|
|
107
|
+
if (!group) return;
|
|
108
|
+
if (emptied.has(proc)) return; // its group was seen gone since: the id may be someone else's now
|
|
109
|
+
// A pid is not given out while a group with that id exists: a live process with the leader's pid means the group was
|
|
110
|
+
// emptied and the id is someone else's now. Members without it are what the leader left (zombies are not listed).
|
|
111
|
+
// Members still finishing their own exit get up to `killMs` before the stop fails.
|
|
112
|
+
for (const end = Date.now() + killMs; ; await Bun.sleep(100)) {
|
|
113
|
+
const rows = await table();
|
|
114
|
+
const reused = rows?.some((r) => r.pid === proc.pid);
|
|
115
|
+
const members = reused ? [] : rows?.filter((r) => r.pgid === proc.pid);
|
|
116
|
+
if (members ? !members.length : groupGone(proc.pid)) return;
|
|
117
|
+
if (Date.now() >= end) throw new Error(`owned child ${proc.pid} exited before the stop and its process group still has members${members ? ` (${members.map((r) => r.pid).join(", ")})` : ""}: not signalled; stop them to restart it`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (group) return stopGroup(proc, proc.pid, termMs, killMs, table);
|
|
121
|
+
try {
|
|
122
|
+
await stopAlone(proc, termMs, killMs);
|
|
123
|
+
} finally {
|
|
124
|
+
dropPipes(proc); // a process it started and left may still hold them: it must not keep the hub alive
|
|
125
|
+
}
|
|
126
|
+
}
|
|
21
127
|
|
|
128
|
+
/** SIGTERM, then SIGKILL, to the child alone, each confirmed by its exit. */
|
|
129
|
+
async function stopAlone(proc: ChildProcess, termMs: number, killMs: number): Promise<void> {
|
|
22
130
|
const waitForExit = (timeoutMs: number): Promise<boolean> =>
|
|
23
131
|
new Promise((resolve) => {
|
|
24
132
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -53,3 +161,134 @@ export async function stopOwnedProcess(proc: ChildProcess, termMs = 1_000, killM
|
|
|
53
161
|
if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(killMs))) return;
|
|
54
162
|
throw new Error(`owned child ${proc.pid} shutdown incomplete after SIGKILL`);
|
|
55
163
|
}
|
|
164
|
+
|
|
165
|
+
const dropPipes = (proc: ChildProcess) => {
|
|
166
|
+
proc.stdout?.destroy();
|
|
167
|
+
proc.stderr?.destroy();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const emptied = new WeakSet<ChildProcess>();
|
|
171
|
+
/**
|
|
172
|
+
* Follows the group of a child spawned `detached` after the child exits, until the group is gone: a later stop then
|
|
173
|
+
* never inspects the group id, which can be reused once the group is empty (issue #113).
|
|
174
|
+
*/
|
|
175
|
+
export function trackGroup(proc: ChildProcess): void {
|
|
176
|
+
proc.once("exit", () => {
|
|
177
|
+
const pid = proc.pid;
|
|
178
|
+
if (pid === undefined) return;
|
|
179
|
+
const check = () => { if (groupGone(pid)) emptied.add(proc); else setTimeout(check, 1_000).unref(); };
|
|
180
|
+
check();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether no process is left in group `pgid` (macOS answers EPERM for a group of zombies). */
|
|
185
|
+
function groupGone(pgid: number): boolean {
|
|
186
|
+
try { process.kill(-pgid, 0); return false; } catch (error) {
|
|
187
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
188
|
+
return code === "ESRCH" || (code === "EPERM" && process.platform === "darwin");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Stops a child that leads its own process group, with everything it started (issue #113). A launcher that forwards
|
|
194
|
+
* signals to a native child (Codex's `codex.js`) otherwise dies alone at SIGKILL while its child, still at work, is
|
|
195
|
+
* re-parented to init with the launcher's pipes and keeps the hub alive; and the native child starts processes that
|
|
196
|
+
* lead groups of their own (Codex's MCP servers and tool commands), which no group signal reaches.
|
|
197
|
+
*
|
|
198
|
+
* SIGTERM to the group, a grace period in which what the tree starts is recorded while its parent still runs, then
|
|
199
|
+
* freeze, enumerate, kill: what is left is stopped (SIGSTOP) before the table is read again, because a stopped process
|
|
200
|
+
* starts nothing, so that read sees all of it; then SIGKILL. A snapshot taken while the tree runs misses what it starts
|
|
201
|
+
* next (three review rounds found such a gap). Done means the table shows none of it; without any table, the group's
|
|
202
|
+
* own answer (ESRCH, or EPERM on macOS for a group of zombies) is used, which cannot see groups below it.
|
|
203
|
+
*/
|
|
204
|
+
async function stopGroup(proc: ChildProcess, pid: number, termMs: number, killMs: number, table: () => ProcRow[] | undefined | Promise<ProcRow[] | undefined>): Promise<void> {
|
|
205
|
+
const key = (r: { pid: number; started: string }) => `${r.pid}@${r.started}`;
|
|
206
|
+
const found = new Map<string, ProcRow>();
|
|
207
|
+
const exited = () => proc.exitCode !== null || proc.signalCode !== null;
|
|
208
|
+
let leader: ProcRow | undefined;
|
|
209
|
+
const live = (rows: ProcRow[], f: ProcRow) => rows.some((r) => r.pid === f.pid && r.started === f.started);
|
|
210
|
+
/** Reads the table and records what runs below the leader or anything recorded that still runs. */
|
|
211
|
+
const look = async (): Promise<ProcRow[] | undefined> => {
|
|
212
|
+
const rows = await table();
|
|
213
|
+
if (!rows) return undefined;
|
|
214
|
+
if (!leader && !exited()) leader = rows.find((r) => r.pid === pid); // never a pid taken over after the leader exited
|
|
215
|
+
const roots = [...(leader && live(rows, leader) ? [leader] : []), ...[...found.values()].filter((f) => live(rows, f))];
|
|
216
|
+
for (const root of roots) for (const r of descendantsOf(rows, root.pid)) found.set(key(r), r);
|
|
217
|
+
// A group is followed while it is known to be the same one: its leader or a recorded member still in it.
|
|
218
|
+
for (const id of [pid, ...[...found.values()].filter((f) => f.pgid === f.pid).map((f) => f.pid)]) {
|
|
219
|
+
// Current rows, never a recorded pgid: a member may have left the group since it was recorded.
|
|
220
|
+
const known = (id === pid && leader && live(rows, leader)) || rows.some((r) => r.pgid === id && r.pid !== id && found.has(key(r))) || rows.some((r) => r.pid === id && r.pgid === id && found.has(key(r)));
|
|
221
|
+
if (known) for (const r of rows) if (r.pgid === id && r.pid !== pid) found.set(key(r), r);
|
|
222
|
+
}
|
|
223
|
+
return rows;
|
|
224
|
+
};
|
|
225
|
+
const left = (rows: ProcRow[]) => [...found.values()].filter((f) => live(rows, f));
|
|
226
|
+
/**
|
|
227
|
+
* Running in the leader's group, or the group of a recorded process, without being recorded: after an empty moment the
|
|
228
|
+
* group id can be someone else's, so it is never signalled, but the stop is not done while it runs.
|
|
229
|
+
*/
|
|
230
|
+
const unproven = (rows: ProcRow[]) => {
|
|
231
|
+
const ids = new Set([pid, ...[...found.values()].filter((f) => f.pgid === f.pid).map((f) => f.pid)]);
|
|
232
|
+
return rows.filter((r) => ids.has(r.pgid) && r.pid !== process.pid && !found.has(key(r)) && !(leader && r.pid === leader.pid && r.started === leader.started));
|
|
233
|
+
};
|
|
234
|
+
// Errors are not results here: the table read back decides. A sent signal says the target existed at that moment.
|
|
235
|
+
const signal = (target: number, sig: NodeJS.Signals) => { try { process.kill(target, sig); return true; } catch { return false; } };
|
|
236
|
+
const each = (rows: ProcRow[], sig: NodeJS.Signals) => rows.filter((r) => signal(r.pgid === r.pid ? -r.pid : r.pid, sig));
|
|
237
|
+
/** A read that takes longer than `ms` is given up: a frozen tree must not wait on a slow `ps` for its SIGKILL. */
|
|
238
|
+
const within = (ms: number) => Promise.race([look(), Bun.sleep(ms).then(() => undefined)]);
|
|
239
|
+
// What leads a group of its own (an MCP server, a tool command) gets a SIGTERM of its own and the same grace period:
|
|
240
|
+
// a git process stopped by SIGKILL leaves its index lock behind.
|
|
241
|
+
const termed = new Set<string>();
|
|
242
|
+
const term = (rows: ProcRow[] | undefined) => {
|
|
243
|
+
for (const r of rows ? left(rows) : []) if (r.pgid === r.pid && !termed.has(key(r))) { termed.add(key(r)); signal(-r.pid, "SIGTERM"); }
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
const first = await look();
|
|
247
|
+
if (!exited()) signal(-pid, "SIGTERM"); // a reaped leader's group id is not proven any more
|
|
248
|
+
term(first);
|
|
249
|
+
// The grace period, for the leader and for what it started: what they start meanwhile is recorded while its parent
|
|
250
|
+
// still runs.
|
|
251
|
+
const leaderGone = new Promise<void>((resolve) => (exited() ? resolve() : proc.once("exit", () => resolve())));
|
|
252
|
+
for (const end = Date.now() + termMs; Date.now() < end; ) {
|
|
253
|
+
// The leader's exit ends a wait at once (a stop is usually that quick); after it, the table is read every 100 ms.
|
|
254
|
+
await (exited() ? Bun.sleep(100) : Promise.race([leaderGone, Bun.sleep(100)]));
|
|
255
|
+
const rows = await look();
|
|
256
|
+
term(rows);
|
|
257
|
+
if (exited() && rows && !left(rows).length) {
|
|
258
|
+
if (!unproven(rows).length) return; // done: this read shows none of it
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Freeze, enumerate, kill: a stopped process starts nothing, so a read after the freeze sees all of it.
|
|
263
|
+
for (const end = Date.now() + killMs; ; ) {
|
|
264
|
+
const rows = await look();
|
|
265
|
+
const rest = rows ? left(rows) : undefined;
|
|
266
|
+
const strangers = rows ? unproven(rows) : [];
|
|
267
|
+
if (exited() && !strangers.length && (rest ? !rest.length : !found.size && groupGone(pid))) return;
|
|
268
|
+
if (Date.now() >= end) {
|
|
269
|
+
throw new Error(`owned child ${pid}: ${rest ? `${rest.length + strangers.length} process(es) of its group or below it still running${strangers.length ? `, ${strangers.length} not proven its own and left alone` : ""}` : "the process table cannot be read to confirm what it started is gone"}`);
|
|
270
|
+
}
|
|
271
|
+
// What is stopped is killed or continued, never left frozen: the group by whether its STOP was sent (its id stays
|
|
272
|
+
// reserved while a member lives), each process by whether the read after the freeze still shows it. What a read
|
|
273
|
+
// after the freeze shows for the first time (started between the read and the STOPs, in a group of its own) is
|
|
274
|
+
// frozen too, and read again, before anything is killed.
|
|
275
|
+
const groupStopped = !exited() && signal(-pid, "SIGSTOP");
|
|
276
|
+
const stopped = new Map<string, ProcRow>();
|
|
277
|
+
let frozen: ProcRow[] | undefined;
|
|
278
|
+
for (let todo = rest ?? [], round = 0; ; round++) {
|
|
279
|
+
for (const r of each(todo, "SIGSTOP")) stopped.set(key(r), r);
|
|
280
|
+
frozen = await within(1_000);
|
|
281
|
+
todo = frozen ? left(frozen).filter((r) => !stopped.has(key(r))) : [];
|
|
282
|
+
if (!todo.length || round >= 4) break;
|
|
283
|
+
}
|
|
284
|
+
// Without a read after the freeze, only what the STOP reached is killed: a stopped process keeps its pid.
|
|
285
|
+
const kill = frozen ? left(frozen) : [...stopped.values()];
|
|
286
|
+
each(kill, "SIGKILL");
|
|
287
|
+
if (groupStopped) signal(-pid, "SIGKILL");
|
|
288
|
+
for (const r of stopped.values()) if (!kill.some((k) => k.pid === r.pid && k.started === r.started)) signal(r.pgid === r.pid ? -r.pid : r.pid, "SIGCONT");
|
|
289
|
+
await Bun.sleep(50);
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
dropPipes(proc); // a process that left the tree between two reads may still hold them: it must not keep the hub alive
|
|
293
|
+
}
|
|
294
|
+
}
|
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,7 @@ 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 { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
31
|
+
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
32
32
|
import { basename, isAbsolute } from "node:path";
|
|
33
33
|
import { realPath } from "./project.ts";
|
|
34
34
|
import type { BusEvent } from "./bus.ts";
|
|
@@ -539,15 +539,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
539
539
|
if (!factsOn()) return undefined;
|
|
540
540
|
let offered: ReturnType<Facts["due"]>;
|
|
541
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" });
|
|
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
543
|
return offered ? { id: offered.id, text: offered.text } : undefined;
|
|
544
544
|
},
|
|
545
545
|
ackFacts: (peer, id) => acked(peer, id, "done"),
|
|
546
546
|
factsCurrent: (peer) => !factsOn() || facts.current(peer),
|
|
547
|
-
|
|
547
|
+
splitProfile: (peer) => splitProfile(peer),
|
|
548
548
|
recordCohort: (c) => event({ type: "cohort", ...c }),
|
|
549
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) }),
|
|
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) }),
|
|
551
551
|
failing: () => bus.failingPeers(),
|
|
552
552
|
held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
|
|
553
553
|
});
|
|
@@ -1018,6 +1018,51 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1018
1018
|
};
|
|
1019
1019
|
} catch { return {}; }
|
|
1020
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 claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
|
|
1027
|
+
const claudeVersion = (): string | undefined => {
|
|
1028
|
+
const path = claudeSession().transcriptPath;
|
|
1029
|
+
if (!path) return undefined;
|
|
1030
|
+
let fd: number | undefined;
|
|
1031
|
+
try {
|
|
1032
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
1033
|
+
const st = fstatSync(fd);
|
|
1034
|
+
if (!st.isFile()) return claudeVersions.get(path);
|
|
1035
|
+
// Asked several times per assignment: the last MiB is read again only once the transcript changed.
|
|
1036
|
+
const stamp = `${st.size}:${st.mtimeMs}`;
|
|
1037
|
+
if (claudeStamps.get(path) === stamp) return claudeVersions.get(path); // no version in it is an answer too
|
|
1038
|
+
const buf = Buffer.alloc(Math.min(st.size, 1024 * 1024));
|
|
1039
|
+
readSync(fd, buf, 0, buf.length, st.size - buf.length);
|
|
1040
|
+
claudeStamps.set(path, stamp); // after the read: one that failed is tried again
|
|
1041
|
+
for (const line of buf.toString("utf8").split("\n").reverse()) {
|
|
1042
|
+
try {
|
|
1043
|
+
const version = JSON.parse(line)?.version;
|
|
1044
|
+
const found = typeof version === "string" ? /^\d+\.\d+\.\d+(?:-[\w.]+)?/.exec(version)?.[0] : undefined;
|
|
1045
|
+
if (found) {
|
|
1046
|
+
claudeVersions.set(path, found);
|
|
1047
|
+
return found;
|
|
1048
|
+
}
|
|
1049
|
+
} catch { /* the cut first line, or not JSON */ }
|
|
1050
|
+
}
|
|
1051
|
+
} catch { /* no transcript yet */ } finally { if (fd !== undefined) closeSync(fd); }
|
|
1052
|
+
return claudeVersions.get(path);
|
|
1053
|
+
};
|
|
1054
|
+
/**
|
|
1055
|
+
* 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
|
|
1056
|
+
* turn-free project runs the facts hooks in Claude and steers facts into Codex). Undefined while a version is unknown.
|
|
1057
|
+
* ponytail: the user's and plugins' hooks are not seen here, and only Claude and Codex report a version; read hooks
|
|
1058
|
+
* from the native records, as the benchmark ledger does, and add other agents' versions when a pair needs them.
|
|
1059
|
+
*/
|
|
1060
|
+
function splitProfile(peer: PeerId): string | undefined {
|
|
1061
|
+
try {
|
|
1062
|
+
const version = peer === "codex" ? (bus.peers.get("codex") as { version?: string } | undefined)?.version : peer === "claude" ? claudeVersion() : undefined;
|
|
1063
|
+
return version ? `hub ${VERSION}; ${peer} ${version}; ${turnFree() ? "turn-free" : "advisory"}` : undefined; // the regime in force: a PII task suspends turn-free
|
|
1064
|
+
} catch { return undefined; } // asked before the daemon finished starting
|
|
1065
|
+
}
|
|
1021
1066
|
/** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
|
|
1022
1067
|
* path Claude itself reported through the status line; fall back to the slug computation. */
|
|
1023
1068
|
const claudeTranscriptPersisted = (sessionId: string): boolean => {
|
|
@@ -1552,7 +1597,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1552
1597
|
// Refused, it never went in: not an unread offer, and the next boundary offers it again. Unanswered, it may
|
|
1553
1598
|
// have: its readback can still come.
|
|
1554
1599
|
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 } : {}) });
|
|
1600
|
+
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 } : {}) });
|
|
1556
1601
|
});
|
|
1557
1602
|
},
|
|
1558
1603
|
appPort: opts.codexAppPort,
|
|
@@ -1657,7 +1702,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1657
1702
|
recoveryTaskPreface("pi");
|
|
1658
1703
|
await ensurePreface("pi");
|
|
1659
1704
|
bus.add(pi);
|
|
1660
|
-
|
|
1705
|
+
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
1706
|
+
try { await pi.start(); } catch (error) { await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`)); throw error; }
|
|
1661
1707
|
return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
|
|
1662
1708
|
}
|
|
1663
1709
|
if (peer === "local") {
|
|
@@ -2143,7 +2189,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2143
2189
|
return void reply({ t: "facts", ok: true });
|
|
2144
2190
|
}
|
|
2145
2191
|
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 } : {}) });
|
|
2192
|
+
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 } : {}) });
|
|
2147
2193
|
return void reply({ t: "facts", ok: true, ...(offered ? { text: offered.text, id: offered.id } : {}) });
|
|
2148
2194
|
} catch (error) {
|
|
2149
2195
|
log(`facts for ${c.peer}: ${(error as Error).message}`);
|
package/src/hub/events.ts
CHANGED
|
@@ -12,10 +12,10 @@ export type HubEvent =
|
|
|
12
12
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
13
13
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
|
14
14
|
| { type: "quiet"; id: string; from: string; peers: string[] }
|
|
15
|
-
| { type: "fact"; peer: string; id: string; files: number; plans: number; unknown: number; bytes: number; via: "hook" | "steer" | "done"; ms?: number; hookMs?: number; rttMs?: number; accepted?: boolean; unanswered?: boolean; probe?: boolean; coverage?: boolean }
|
|
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
16
|
| { type: "fact_ack"; peer: string; id: string; via: string; ms: number }
|
|
17
17
|
| { type: "capability"; peer: string; state: "verified" | "lost"; via?: string }
|
|
18
|
-
| { type: "split"; task: number; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
|
|
18
|
+
| { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
|
|
19
19
|
| { type: "state"; peer: string; state: string }
|
|
20
20
|
| { type: "turn_start"; peer: string; turn: string }
|
|
21
21
|
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|