@staix/agent-hub 0.14.0 → 0.16.0
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 +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join, sep } from "node:path";
|
|
4
|
+
import { realPath } from "../hub/project.ts";
|
|
5
|
+
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Kimi's session wire log as a usage source (#285 phase 2). This file is the only code that knows the format.
|
|
9
|
+
*
|
|
10
|
+
* Format followed: `<kimi home>/session_index.jsonl` rows `{ sessionId, sessionDir, ... }`, and per session
|
|
11
|
+
* `<sessionDir>/agents/<agent>/wire.jsonl`, one JSON object per line. The first line is
|
|
12
|
+
* `{ type: "metadata", protocol_version }`; each model request writes
|
|
13
|
+
* `{ type: "usage.record", agentId, model, usage: { inputOther, output, inputCacheRead, inputCacheCreation }, usageScope, time }`.
|
|
14
|
+
* Read from the wire logs of 524 sessions on one machine, all `protocol_version` 1.5, with the hub's own Kimi 2.3.0 install (the issue
|
|
15
|
+
* recorded the same rows on 2.1.1; the hub verifies 2.2.0 and 2.3.0 in `compat.ts`). Which build wrote which log is not recorded in
|
|
16
|
+
* them, and the live legs of #285 AC11 are a separate person receipt, so a protocol this file has not seen is `unknown`, never a count.
|
|
17
|
+
*
|
|
18
|
+
* Rules: the file is private and holds prompts, so only the four integers, the model label and the time are copied out of a usage
|
|
19
|
+
* row, nothing is logged, and a line is looked at only when it is short. An error, a shrunk or replaced file, an unknown row shape, a
|
|
20
|
+
* truncated last line or a missing index gives `unknown` and never a partial count: a read commits its offsets only when every file
|
|
21
|
+
* of the session was read cleanly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const KIMI_WIRE_PROTOCOLS: readonly string[] = ["1.5"];
|
|
25
|
+
|
|
26
|
+
export type KimiWireReason =
|
|
27
|
+
| "no-index" | "outside-home" | "io-error" | "partial-line" | "no-metadata" | "untested-protocol"
|
|
28
|
+
| "malformed-row" | "unknown-row-shape" | "oversized-row" | "shrunk" | "rotated" | "missing" | "moved";
|
|
29
|
+
|
|
30
|
+
/** One model request. `id` is opaque and stable across hub restarts: a hash of the session, the agent and the row's byte offset. */
|
|
31
|
+
export interface KimiWireRow {
|
|
32
|
+
id: string;
|
|
33
|
+
/** epoch ms the row was written, from Kimi's own clock */
|
|
34
|
+
at: number;
|
|
35
|
+
model?: string;
|
|
36
|
+
inputOther: number;
|
|
37
|
+
inputCacheCreation: number;
|
|
38
|
+
inputCacheRead: number;
|
|
39
|
+
output: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type KimiWireRead =
|
|
43
|
+
| { status: "ok"; rows: KimiWireRow[]; /** bytes remain unread because of the per-call bound: read again */ more: boolean }
|
|
44
|
+
| { status: "unknown"; reason: KimiWireReason };
|
|
45
|
+
|
|
46
|
+
/** What counts against `budget.kimi_tokens_5h`: new input, cache writes and output. Cache reads are recorded and shown, never counted. */
|
|
47
|
+
export function kimiCountedTokens(row: Pick<KimiWireRow, "inputOther" | "inputCacheCreation" | "output">): number {
|
|
48
|
+
return row.inputOther + row.inputCacheCreation + row.output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Everything the row's four counters add up to, the cache reads included (the `usage` event's `totalTokens`). */
|
|
52
|
+
export function kimiRowTotal(row: Pick<KimiWireRow, "inputOther" | "inputCacheCreation" | "inputCacheRead" | "output">): number {
|
|
53
|
+
return kimiCountedTokens(row) + row.inputCacheRead;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface KimiWireOptions {
|
|
57
|
+
/** Kimi's data home (`KIMI_CODE_HOME`, else `~/.kimi-code`). */
|
|
58
|
+
home: string;
|
|
59
|
+
/** The ACP session id the hub owns; matched exactly against `session_index.jsonl`. */
|
|
60
|
+
sessionId: string;
|
|
61
|
+
/** Bytes read per call in total, across every file of the session (the call runs on the daemon's event loop). */
|
|
62
|
+
maxBytes?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
66
|
+
const AGENT_NAME = /^[A-Za-z0-9._-]{1,64}$/;
|
|
67
|
+
const MAX_AGENTS = 256;
|
|
68
|
+
/** A usage row is a few hundred bytes. A longer line is a prompt, a message or a tool output: skipped without being parsed. */
|
|
69
|
+
const LINE_CAP = 64 * 1024;
|
|
70
|
+
const INDEX_CAP = 16 * 1024 * 1024;
|
|
71
|
+
const DEFAULT_CHUNK = 1024 * 1024;
|
|
72
|
+
const MARKER = Buffer.from('"usage.record"');
|
|
73
|
+
const TRANSIENT: ReadonlySet<KimiWireReason> = new Set(["no-index", "io-error", "partial-line"]);
|
|
74
|
+
|
|
75
|
+
/** `print` is the content fingerprint of the consumed region at `offset` (see `fingerprint`): identity that survives inode reuse. */
|
|
76
|
+
interface FileState { dev: number; ino: number; offset: number; skipping: boolean; print: string }
|
|
77
|
+
/** The bytes the fingerprint covers at each end of the consumed region: Kimi's metadata line (with its creation time) and the last bytes read. */
|
|
78
|
+
const PRINT_HEAD = 64, PRINT_TAIL = 256;
|
|
79
|
+
class Unknown extends Error { constructor(readonly reason: KimiWireReason) { super(reason); } }
|
|
80
|
+
|
|
81
|
+
const count = (value: unknown): number | undefined => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
82
|
+
const inside = (root: string, path: string): boolean => path.startsWith(root + sep);
|
|
83
|
+
|
|
84
|
+
export class KimiWireReader {
|
|
85
|
+
private readonly home: string;
|
|
86
|
+
private readonly sessionId: string;
|
|
87
|
+
private readonly chunk: number;
|
|
88
|
+
private dir: string | undefined;
|
|
89
|
+
private files = new Map<string, FileState>();
|
|
90
|
+
private broken: KimiWireReason | undefined;
|
|
91
|
+
|
|
92
|
+
constructor(options: KimiWireOptions) {
|
|
93
|
+
this.home = options.home;
|
|
94
|
+
this.sessionId = options.sessionId;
|
|
95
|
+
this.chunk = Math.max(LINE_CAP * 2, options.maxBytes ?? DEFAULT_CHUNK);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The rows written since the last successful read, sub-agents included. */
|
|
99
|
+
read(): KimiWireRead {
|
|
100
|
+
if (this.broken) return { status: "unknown", reason: this.broken };
|
|
101
|
+
try {
|
|
102
|
+
const dir = this.resolve();
|
|
103
|
+
const next = new Map<string, FileState>();
|
|
104
|
+
const rows: KimiWireRow[] = [];
|
|
105
|
+
let more = false, budget = this.chunk;
|
|
106
|
+
const agents = this.agents(dir);
|
|
107
|
+
for (const name of this.files.keys()) if (!agents.has(name)) throw new Unknown("missing");
|
|
108
|
+
for (const [name, wire] of agents) {
|
|
109
|
+
const previous = this.files.get(name);
|
|
110
|
+
if (budget <= 0) { if (previous) next.set(name, previous); more = true; continue; } // the bound is spent: this file waits for the next call
|
|
111
|
+
const result = this.readFile(name, wire, previous, budget);
|
|
112
|
+
next.set(name, result.state);
|
|
113
|
+
rows.push(...result.rows);
|
|
114
|
+
budget -= result.read;
|
|
115
|
+
more ||= result.more;
|
|
116
|
+
}
|
|
117
|
+
this.files = next;
|
|
118
|
+
return { status: "ok", rows, more };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const reason: KimiWireReason = error instanceof Unknown ? error.reason : "io-error";
|
|
121
|
+
if (!TRANSIENT.has(reason)) this.broken = reason; // the log no longer matches what was counted: stays unknown for this session
|
|
122
|
+
return { status: "unknown", reason };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The session directory from its index row, resolved once with realPath and kept inside `<home>/sessions`. */
|
|
127
|
+
private resolve(): string {
|
|
128
|
+
if (!SESSION_ID.test(this.sessionId)) throw new Unknown("no-index");
|
|
129
|
+
let root: string;
|
|
130
|
+
try { root = realPath(join(this.home, "sessions")); } catch { throw new Unknown("no-index"); }
|
|
131
|
+
if (this.dir) {
|
|
132
|
+
let again: string;
|
|
133
|
+
try { again = realPath(this.dir); } catch { throw new Unknown("moved"); }
|
|
134
|
+
if (again !== this.dir) throw new Unknown("moved");
|
|
135
|
+
return this.dir;
|
|
136
|
+
}
|
|
137
|
+
const text = this.index();
|
|
138
|
+
let listed: string | undefined;
|
|
139
|
+
for (const line of text.split("\n")) {
|
|
140
|
+
if (!line.includes(this.sessionId)) continue;
|
|
141
|
+
try {
|
|
142
|
+
const row = JSON.parse(line);
|
|
143
|
+
if (row && row.sessionId === this.sessionId && typeof row.sessionDir === "string" && row.sessionDir) listed = row.sessionDir;
|
|
144
|
+
} catch { /* a line that is not ours is not our problem */ }
|
|
145
|
+
}
|
|
146
|
+
if (!listed) throw new Unknown("no-index");
|
|
147
|
+
let dir: string;
|
|
148
|
+
try { dir = realPath(listed); } catch { throw new Unknown("no-index"); }
|
|
149
|
+
if (!inside(root, dir)) throw new Unknown("outside-home");
|
|
150
|
+
this.dir = dir;
|
|
151
|
+
return dir;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The index, opened once and sized from that descriptor, so a link swapped in between a check and the read cannot lift the bound. */
|
|
155
|
+
private index(): string {
|
|
156
|
+
let fd: number;
|
|
157
|
+
try { fd = openSync(join(this.home, "session_index.jsonl"), "r"); } catch { throw new Unknown("no-index"); }
|
|
158
|
+
try {
|
|
159
|
+
const stat = fstatSync(fd);
|
|
160
|
+
if (!stat.isFile() || stat.size > INDEX_CAP) throw new Unknown("io-error");
|
|
161
|
+
const buffer = Buffer.allocUnsafe(stat.size);
|
|
162
|
+
let got = 0;
|
|
163
|
+
while (got < stat.size) {
|
|
164
|
+
const n = readSync(fd, buffer, got, stat.size - got, got);
|
|
165
|
+
if (n <= 0) break;
|
|
166
|
+
got += n;
|
|
167
|
+
}
|
|
168
|
+
return buffer.toString("utf8", 0, got);
|
|
169
|
+
} catch (error) { throw error instanceof Unknown ? error : new Unknown("io-error"); }
|
|
170
|
+
finally { closeSync(fd); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `agents/<name>/wire.jsonl` of every agent that has one: the main agent and its sub-agents. */
|
|
174
|
+
private agents(dir: string): Map<string, string> {
|
|
175
|
+
const out = new Map<string, string>();
|
|
176
|
+
const files = new Set<string>(); // one file seen under two names (a link, a hard link) is counted once
|
|
177
|
+
let names: string[];
|
|
178
|
+
try { names = readdirSync(join(dir, "agents")).filter(name => AGENT_NAME.test(name)).sort(); }
|
|
179
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return out; throw new Unknown("io-error"); }
|
|
180
|
+
if (names.length > MAX_AGENTS) throw new Unknown("io-error");
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
const path = join(dir, "agents", name, "wire.jsonl");
|
|
183
|
+
try { lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Unknown("io-error"); }
|
|
184
|
+
let real: string;
|
|
185
|
+
try { real = realPath(path); } catch { throw new Unknown("io-error"); }
|
|
186
|
+
if (!inside(dir, real)) throw new Unknown("outside-home");
|
|
187
|
+
let identity: string;
|
|
188
|
+
try { const stat = statSync(real); identity = `${stat.dev}:${stat.ino}`; } catch { throw new Unknown("io-error"); }
|
|
189
|
+
if (files.has(identity)) continue;
|
|
190
|
+
files.add(identity);
|
|
191
|
+
out.set(name, real);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A hash of the first and last bytes of what was consumed, a pure function of the file's content and the offset. A file replaced by
|
|
198
|
+
* another (log rotation, a restored session) can get the same device and inode on Linux, and from the old offset its new content parses
|
|
199
|
+
* mid-line: the stored fingerprint no longer matches, which is `rotated`, never a read.
|
|
200
|
+
*/
|
|
201
|
+
private fingerprint(fd: number, offset: number): string {
|
|
202
|
+
if (offset <= 0) return "";
|
|
203
|
+
const hash = createHash("sha256");
|
|
204
|
+
for (const [start, length] of [[0, Math.min(offset, PRINT_HEAD)], [Math.max(0, offset - PRINT_TAIL), Math.min(offset, PRINT_TAIL)]] as const) {
|
|
205
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
206
|
+
let got = 0;
|
|
207
|
+
while (got < length) {
|
|
208
|
+
let n: number;
|
|
209
|
+
try { n = readSync(fd, buffer, got, length - got, start + got); } catch { throw new Unknown("io-error"); }
|
|
210
|
+
if (n <= 0) throw new Unknown("shrunk");
|
|
211
|
+
got += n;
|
|
212
|
+
}
|
|
213
|
+
hash.update(buffer);
|
|
214
|
+
}
|
|
215
|
+
return hash.digest("hex");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private readFile(agent: string, path: string, previous: FileState | undefined, limit: number): { state: FileState; rows: KimiWireRow[]; more: boolean; read: number } {
|
|
219
|
+
let fd: number;
|
|
220
|
+
try { fd = openSync(path, "r"); } catch { throw new Unknown("io-error"); }
|
|
221
|
+
try {
|
|
222
|
+
const stat = fstatSync(fd);
|
|
223
|
+
if (!stat.isFile()) throw new Unknown("io-error");
|
|
224
|
+
if (previous && (previous.dev !== stat.dev || previous.ino !== stat.ino)) throw new Unknown("rotated");
|
|
225
|
+
if (previous && stat.size < previous.offset) throw new Unknown("shrunk");
|
|
226
|
+
if (previous && this.fingerprint(fd, previous.offset) !== previous.print) throw new Unknown("rotated");
|
|
227
|
+
const state: FileState = previous ? { ...previous } : { dev: stat.dev, ino: stat.ino, offset: 0, skipping: false, print: "" };
|
|
228
|
+
const started = state.offset;
|
|
229
|
+
const seal = (done: FileState): FileState => ({ ...done, print: done.offset === started && previous ? done.print : this.fingerprint(fd, done.offset) });
|
|
230
|
+
const rows: KimiWireRow[] = [];
|
|
231
|
+
const length = Math.min(limit, stat.size - state.offset);
|
|
232
|
+
if (length <= 0) return { state, rows, more: false, read: 0 };
|
|
233
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
234
|
+
let got = 0;
|
|
235
|
+
while (got < length) {
|
|
236
|
+
let n: number;
|
|
237
|
+
try { n = readSync(fd, buffer, got, length - got, state.offset + got); } catch { throw new Unknown("io-error"); }
|
|
238
|
+
if (n <= 0) break;
|
|
239
|
+
got += n;
|
|
240
|
+
}
|
|
241
|
+
if (got < length) throw new Unknown("shrunk");
|
|
242
|
+
const base = state.offset;
|
|
243
|
+
const reachedEnd = base + length >= stat.size;
|
|
244
|
+
let pos = 0;
|
|
245
|
+
if (state.skipping) {
|
|
246
|
+
const end = buffer.indexOf(10, 0);
|
|
247
|
+
if (end < 0) return { state: seal({ ...state, offset: base + length }), rows, more: !reachedEnd, read: length };
|
|
248
|
+
pos = end + 1;
|
|
249
|
+
state.skipping = false;
|
|
250
|
+
}
|
|
251
|
+
while (pos < length) {
|
|
252
|
+
const end = buffer.indexOf(10, pos);
|
|
253
|
+
if (end < 0) {
|
|
254
|
+
const tail = length - pos;
|
|
255
|
+
if (tail >= LINE_CAP) {
|
|
256
|
+
// A line longer than any usage row: refuse only when it looks like one, else skip it unread.
|
|
257
|
+
if (buffer.subarray(pos, pos + 256).includes(MARKER) || base + pos === 0) throw new Unknown(base + pos === 0 ? "no-metadata" : "oversized-row");
|
|
258
|
+
state.skipping = true;
|
|
259
|
+
pos = length;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
if (reachedEnd) throw new Unknown("partial-line");
|
|
263
|
+
break; // the chunk ended inside a line: it is read whole next call
|
|
264
|
+
}
|
|
265
|
+
const lineStart = base + pos, size = end - pos;
|
|
266
|
+
if (size > LINE_CAP) {
|
|
267
|
+
if (buffer.subarray(pos, pos + 256).includes(MARKER) || lineStart === 0) throw new Unknown(lineStart === 0 ? "no-metadata" : "oversized-row");
|
|
268
|
+
} else if (lineStart === 0) {
|
|
269
|
+
this.metadata(buffer.toString("utf8", pos, end));
|
|
270
|
+
} else if (buffer.subarray(pos, end).includes(MARKER)) {
|
|
271
|
+
const row = this.row(agent, lineStart, buffer.toString("utf8", pos, end));
|
|
272
|
+
if (row) rows.push(row);
|
|
273
|
+
}
|
|
274
|
+
pos = end + 1;
|
|
275
|
+
}
|
|
276
|
+
state.offset = base + pos;
|
|
277
|
+
return { state: seal(state), rows, more: state.offset < stat.size, read: length };
|
|
278
|
+
} finally { closeSync(fd); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private metadata(line: string): void {
|
|
282
|
+
let value: any;
|
|
283
|
+
try { value = JSON.parse(line); } catch { throw new Unknown("no-metadata"); }
|
|
284
|
+
if (!value || value.type !== "metadata" || typeof value.protocol_version !== "string") throw new Unknown("no-metadata");
|
|
285
|
+
if (!KIMI_WIRE_PROTOCOLS.includes(value.protocol_version)) throw new Unknown("untested-protocol");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A line that mentions `usage.record`: a usage row, or (valid JSON of another type) something that only quotes it. */
|
|
289
|
+
private row(agent: string, offset: number, line: string): KimiWireRow | undefined {
|
|
290
|
+
let value: any;
|
|
291
|
+
try { value = JSON.parse(line); } catch { throw new Unknown("malformed-row"); }
|
|
292
|
+
if (!value || typeof value !== "object" || value.type !== "usage.record") return undefined;
|
|
293
|
+
const usage = value.usage;
|
|
294
|
+
const inputOther = count(usage?.inputOther), output = count(usage?.output), inputCacheRead = count(usage?.inputCacheRead), inputCacheCreation = count(usage?.inputCacheCreation);
|
|
295
|
+
const at = value.time;
|
|
296
|
+
if (inputOther === undefined || output === undefined || inputCacheRead === undefined || inputCacheCreation === undefined
|
|
297
|
+
|| (value.usageScope !== "turn" && value.usageScope !== "session")
|
|
298
|
+
|| typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0 || at > 8.64e15) throw new Unknown("unknown-row-shape");
|
|
299
|
+
const model = safeModelLabel(value.model);
|
|
300
|
+
const id = createHash("sha256").update(this.sessionId).update("\0").update(agent).update("\0").update(String(offset)).digest("hex").slice(0, 32);
|
|
301
|
+
return { id, at, ...(model ? { model } : {}), inputOther, inputCacheCreation, inputCacheRead, output };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -235,7 +235,7 @@ export class LocalPeer extends BasePeer {
|
|
|
235
235
|
): Promise<string> {
|
|
236
236
|
const { maxSteps = 30 } = this.opts;
|
|
237
237
|
const turnSignal = this.abort!.signal;
|
|
238
|
-
//
|
|
238
|
+
// Local capture preserves the host privacy fence: nothing of a PII turn is recorded.
|
|
239
239
|
const capture = policy?.pii ? undefined : this.opts.capture;
|
|
240
240
|
// Unknown gateway state is never permission to send PII.
|
|
241
241
|
if (policy?.pii && !(await this.opts.omni.onCampus())) {
|
|
@@ -245,6 +245,7 @@ export class LocalPeer extends BasePeer {
|
|
|
245
245
|
this.routePii = !!policy?.pii;
|
|
246
246
|
const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
|
|
247
247
|
let usedTools = false;
|
|
248
|
+
let captureInvocation = 0;
|
|
248
249
|
for (let step = 0; step < maxSteps; step++) {
|
|
249
250
|
this.elide(msgs);
|
|
250
251
|
const res = await this.call(msgs, policy, envs);
|
|
@@ -280,7 +281,7 @@ export class LocalPeer extends BasePeer {
|
|
|
280
281
|
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
281
282
|
this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
282
283
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
283
|
-
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
284
|
+
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
284
285
|
}
|
|
285
286
|
} finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
|
|
286
287
|
}
|
|
@@ -293,7 +294,7 @@ export class LocalPeer extends BasePeer {
|
|
|
293
294
|
cwd: this.opts.cwd,
|
|
294
295
|
deny: this.opts.tools.deny,
|
|
295
296
|
readOnly: this.opts.tools.readOnly,
|
|
296
|
-
permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath) => {
|
|
297
|
+
permit: async (title, tool, _signal, canonicalTarget, _observe, originalPath, input) => {
|
|
297
298
|
if (turnSignal.aborted) return "aborted";
|
|
298
299
|
const generation = this.turn;
|
|
299
300
|
let observed = false;
|
|
@@ -313,7 +314,7 @@ export class LocalPeer extends BasePeer {
|
|
|
313
314
|
const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
|
|
314
315
|
const onAbort = () => finish("aborted");
|
|
315
316
|
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
316
|
-
this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
317
|
+
this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe, originalPath, input).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
317
318
|
});
|
|
318
319
|
if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
|
|
319
320
|
// Legacy permit callbacks can represent a person's denial without provenance. A true
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ClaudeImplementerOptions } from "./claude-implementer.ts";
|
|
1
2
|
import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
2
3
|
import type { SettingDef } from "../hub/settings.ts";
|
|
3
4
|
import type { Addon } from "../hub/tooling.ts";
|
|
@@ -20,7 +21,7 @@ import type { BuiltinCliEntry } from "./definitions/native-cli-entry-types.ts";
|
|
|
20
21
|
import type { NativePreviewBuilder } from "./definitions/native-cli-preview-common.ts";
|
|
21
22
|
|
|
22
23
|
export type StartForm = "tui" | "headless";
|
|
23
|
-
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
|
|
24
|
+
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print" | "claude-implementer";
|
|
24
25
|
|
|
25
26
|
/** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
|
|
26
27
|
export interface LaunchContext {
|
|
@@ -62,7 +63,8 @@ export type DaemonPorts =
|
|
|
62
63
|
| { pi: PiOptions }
|
|
63
64
|
| { local: LocalOptions }
|
|
64
65
|
| { claudePrint: ClaudePrintOptions }
|
|
65
|
-
| { reviewerPrint: ReviewerPrintOptions }
|
|
66
|
+
| { reviewerPrint: ReviewerPrintOptions }
|
|
67
|
+
| { claudeImplementer: ClaudeImplementerOptions };
|
|
66
68
|
|
|
67
69
|
export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
68
70
|
instance: string;
|
|
@@ -200,7 +202,7 @@ export interface AdapterDefinition {
|
|
|
200
202
|
ownedFlags: readonly string[];
|
|
201
203
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
202
204
|
};
|
|
203
|
-
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
|
|
205
|
+
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
|
|
204
206
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
205
207
|
instances?: { mode: "external" | "headless"; roles?: readonly string[];
|
|
206
208
|
/** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
|
|
@@ -268,7 +270,7 @@ export interface AdapterDefinition {
|
|
|
268
270
|
trace?: "route" | "backend";
|
|
269
271
|
backendLabel?: string;
|
|
270
272
|
};
|
|
271
|
-
budget?: { account: string; quotaExempt?: boolean; instrumented?: boolean; reader?: QuotaReader; context?: ContextReader; checkpoint?: boolean; resetSoftTokensOnResume?: boolean };
|
|
273
|
+
budget?: { account: string; quotaExempt?: boolean; /** Why this peer has no provider quota, shown by `ahub budget` and the dashboard (#285 AC6). */ noQuotaReason?: string; instrumented?: boolean; reader?: QuotaReader; context?: ContextReader; checkpoint?: boolean; resetSoftTokensOnResume?: boolean };
|
|
272
274
|
configuration?: {
|
|
273
275
|
schema?(ports: NativeConfigValidators): Record<string, NativeConfigCheck>;
|
|
274
276
|
routingSchema?(ports: NativeConfigValidators): Record<string, NativeConfigCheck>;
|
|
@@ -300,6 +302,8 @@ export interface AdapterDefinition {
|
|
|
300
302
|
hooks?: Partial<NativeHooks>;
|
|
301
303
|
/** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
|
|
302
304
|
reviewerVariant?: AdapterDefinition;
|
|
305
|
+
/** #525: fixed builtin variant for declared headless implementer instances. */
|
|
306
|
+
headlessVariant?: AdapterDefinition;
|
|
303
307
|
/** Fixed host backend; runtime isolation proof is required before bus admission. */
|
|
304
308
|
reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
|
|
305
309
|
}
|
package/src/adapters/registry.ts
CHANGED
|
@@ -24,7 +24,10 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION, AGY_REVIEWER_VARIANT]);
|
|
27
|
-
for (const definition of [...fixedBuiltinDefinitions])
|
|
27
|
+
for (const definition of [...fixedBuiltinDefinitions]) {
|
|
28
|
+
if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
|
|
29
|
+
if (definition.headlessVariant) fixedBuiltinDefinitions.add(definition.headlessVariant);
|
|
30
|
+
}
|
|
28
31
|
|
|
29
32
|
/** Keep the native catalog's order, independently of the legacy default-role object order. */
|
|
30
33
|
export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
@@ -65,6 +68,7 @@ for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
|
65
68
|
assertDefinitionCoherence(definition);
|
|
66
69
|
assertNativeCapabilityScope(definition);
|
|
67
70
|
if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
71
|
+
if (definition.headlessVariant) { assertDefinitionCoherence(definition.headlessVariant); assertNativeCapabilityScope(definition.headlessVariant); }
|
|
68
72
|
if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
|
|
69
73
|
definitions.set(definition.kind, definition);
|
|
70
74
|
}
|
|
@@ -114,7 +118,11 @@ function resolvedDefinition(id: string, kind: string | undefined, instances: Kin
|
|
|
114
118
|
const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
|
|
115
119
|
const variant = definition?.reviewerVariant;
|
|
116
120
|
const fresh = variant?.reviewer?.declaration === "automatic" || ownField(declaration, "review_mode") === "fresh";
|
|
117
|
-
|
|
121
|
+
const headless = kind !== id && ownField(declaration, "start_mode") === "headless";
|
|
122
|
+
if (variant && headless && reviewerOnly && fresh) return variant;
|
|
123
|
+
const roleValues = Array.isArray(roles) && !types.isProxy(roles) && roles.length <= 4 ? Array.from({ length: roles.length }, (_, index) => Object.getOwnPropertyDescriptor(roles, String(index))?.value) : [];
|
|
124
|
+
const writerRoles = roleValues.includes("implementer") && roleValues.every(role => role === "implementer" || role === "verifier");
|
|
125
|
+
return headless && writerRoles && definition?.headlessVariant ? definition.headlessVariant : definition;
|
|
118
126
|
}
|
|
119
127
|
export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
120
128
|
return definitionOf(id, instances)?.family;
|
|
@@ -217,6 +225,7 @@ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
|
|
|
217
225
|
}
|
|
218
226
|
}
|
|
219
227
|
if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
|
|
228
|
+
if (definition.headlessVariant && (definition.headlessVariant.transport !== "hub-native" || !definition.headlessVariant.start.create || definition.headlessVariant.instances?.mode !== "headless")) fail("headlessVariant", "implementer variant requires its headless hub-native factory");
|
|
220
229
|
if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
|
|
221
230
|
}
|
|
222
231
|
|
|
@@ -233,7 +242,10 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
233
242
|
["routing", definition.routing !== undefined],
|
|
234
243
|
["projection", definition.projection !== undefined],
|
|
235
244
|
["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
|
|
245
|
+
["identity.currentChild", declared(definition.identity, "currentChild")],
|
|
236
246
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
247
|
+
["headlessVariant", definition.headlessVariant !== undefined],
|
|
248
|
+
["start.daemonPort claude-implementer", definition.start.daemonPort === "claude-implementer"],
|
|
237
249
|
["reviewer", definition.reviewer !== undefined],
|
|
238
250
|
["hooks.factsHook", declared(definition.hooks, "factsHook")],
|
|
239
251
|
["cli.entry", declared(definition.cli, "entry")],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
4
4
|
import { HUB, frame } from "../hub/envelope.ts";
|
|
5
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
6
|
import type { Envelope } from "../hub/envelope.ts";
|
|
@@ -107,9 +107,6 @@ export interface ReviewerPrintOptions {
|
|
|
107
107
|
bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
|
|
111
|
-
const MAX_REVIEW_FRAME_BYTES = 256 * 1024, MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
112
|
-
class GroupCleanupError extends Error {}
|
|
113
110
|
type ReviewTarget = Omit<ReviewerFailure, "cause">;
|
|
114
111
|
interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
|
|
115
112
|
const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
@@ -129,8 +126,7 @@ const reviewTask = (env: Envelope): number | undefined => {
|
|
|
129
126
|
export class ReviewerPrintPeer extends BasePeer {
|
|
130
127
|
readonly hubNative = true;
|
|
131
128
|
private generation = 0;
|
|
132
|
-
private child:
|
|
133
|
-
private childStop: ((child: ChildProcess) => Promise<void>) | undefined;
|
|
129
|
+
private child: OwnedPrintChild | undefined;
|
|
134
130
|
private childCleanup: (() => Promise<void>) | undefined;
|
|
135
131
|
private childCurrent: (() => boolean) | undefined;
|
|
136
132
|
private reviewMcpClose: (() => void) | undefined;
|
|
@@ -242,7 +238,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
242
238
|
try { result = await this.review(env, task, generation); }
|
|
243
239
|
catch (error) {
|
|
244
240
|
if (generation !== this.generation) return;
|
|
245
|
-
if (error instanceof
|
|
241
|
+
if (error instanceof PrintChildCleanupError) {
|
|
246
242
|
// Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
|
|
247
243
|
this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
|
|
248
244
|
this.settle("needs_review", `review process group still running: ${error.message}`);
|
|
@@ -329,60 +325,41 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
329
325
|
let mcpClosed=false;
|
|
330
326
|
const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
|
|
331
327
|
this.reviewMcpClose=closeMcp;
|
|
332
|
-
let ownedChild:
|
|
328
|
+
let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
|
|
333
329
|
let policyTimer: ReturnType<typeof setInterval> | undefined;
|
|
334
330
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
335
331
|
try {
|
|
336
332
|
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
337
333
|
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
|
|
338
334
|
this.receipt = launch.isolation;
|
|
339
|
-
const [cmd, ...prefix] = launch.command;
|
|
340
335
|
let quota = false;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
ownedChild = child;
|
|
347
|
-
this.childStop = launch!.stop;
|
|
348
|
-
this.childCleanup = release;
|
|
349
|
-
this.childCurrent = launch!.watchCurrent ? launch!.current : undefined;
|
|
350
|
-
child.once("error", error => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } reject(error); });
|
|
351
|
-
child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
|
|
352
|
-
child.once("spawn", () => {
|
|
353
|
-
trackGroup(child);
|
|
354
|
-
if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
|
|
336
|
+
const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
|
|
337
|
+
cwd: launch.cwd ?? this.opts.cwd, env: launch.env, envMode: launch.envMode, input,
|
|
338
|
+
current: () => generation === this.generation, stop: launch.stop,
|
|
339
|
+
onError: () => { if (this.child === child) this.child = undefined; },
|
|
340
|
+
onSpawn: () => {
|
|
355
341
|
this.runningBinding={task,binding};
|
|
356
342
|
this.reviewing = task; this.reviewSeq++;
|
|
357
343
|
if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
const event: unknown = JSON.parse(line);
|
|
371
|
-
const usage = this.opts.backend.usage?.(event);
|
|
372
|
-
if (usage) this.opts.onUsage?.(usage, task);
|
|
373
|
-
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
374
|
-
} catch { /* not a stream-json line */ }
|
|
375
|
-
}
|
|
376
|
-
if (Buffer.byteLength(buffer) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
377
|
-
this.touch();
|
|
378
|
-
});
|
|
379
|
-
child.once("exit", code => {
|
|
380
|
-
if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; } // the verdict authority ends with the leader
|
|
381
|
-
// The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
|
|
382
|
-
(launch!.stop ? launch!.stop(child) : stopOwnedProcess(child, { group: true })).then(() => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } resolve({ code, quota }); },
|
|
383
|
-
(error: Error) => reject(new GroupCleanupError(error.message)));
|
|
384
|
-
});
|
|
344
|
+
},
|
|
345
|
+
onFrame: event => {
|
|
346
|
+
const usage = this.opts.backend.usage?.(event);
|
|
347
|
+
if (usage) this.opts.onUsage?.(usage, task);
|
|
348
|
+
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
349
|
+
},
|
|
350
|
+
onActivity: () => this.touch(),
|
|
351
|
+
onLimit: () => this.abortReview("review output limit exceeded"),
|
|
352
|
+
onLeaderExit: () => {
|
|
353
|
+
if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; }
|
|
354
|
+
},
|
|
385
355
|
});
|
|
356
|
+
// Owned before any await, so a stop that lands before "spawn" still finds and kills it.
|
|
357
|
+
this.child = ownedChild = child;
|
|
358
|
+
this.childCleanup = release;
|
|
359
|
+
this.childCurrent = launch.watchCurrent ? launch.current : undefined;
|
|
360
|
+
const code = await child.done;
|
|
361
|
+
if (this.child === child) this.child = undefined;
|
|
362
|
+
return { code, quota };
|
|
386
363
|
} finally {
|
|
387
364
|
closeMcp();
|
|
388
365
|
if (policyTimer) clearInterval(policyTimer);
|
|
@@ -403,8 +380,8 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
403
380
|
this.runningBinding=undefined;
|
|
404
381
|
const child = this.child;
|
|
405
382
|
if (!child) return;
|
|
406
|
-
await
|
|
407
|
-
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.
|
|
383
|
+
await child.stop();
|
|
384
|
+
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
|
|
408
385
|
}
|
|
409
386
|
}
|
|
410
387
|
|