@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +23 -9
  3. package/docs/agent-notes/README.md +8 -0
  4. package/docs/agent-notes/adapters.md +21 -4
  5. package/docs/agent-notes/budget.md +6 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +20 -9
  8. package/docs/agent-notes/local-worker.md +2 -2
  9. package/docs/agent-notes/tasks.md +21 -4
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/dogfood.md +19 -5
  12. package/docs/events.md +21 -7
  13. package/docs/memory.md +78 -10
  14. package/docs/operations.md +197 -39
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +16 -6
  17. package/docs/sdlc.md +17 -0
  18. package/docs/security.md +110 -21
  19. package/docs/smoke.md +7 -3
  20. package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
  21. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  22. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  23. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  24. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  25. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  26. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  27. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  28. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  29. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  30. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  31. package/docs/verified.json +40 -19
  32. package/package.json +2 -2
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +3109 -2046
  35. package/src/adapters/acp.ts +46 -7
  36. package/src/adapters/claude-channel.ts +26 -4
  37. package/src/adapters/claude-implementer.ts +243 -0
  38. package/src/adapters/claude-print-child.ts +90 -0
  39. package/src/adapters/claude-task-sessions.ts +80 -0
  40. package/src/adapters/codex-appserver.ts +69 -1
  41. package/src/adapters/compat.ts +6 -2
  42. package/src/adapters/conformance/enrollment.ts +2 -1
  43. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  44. package/src/adapters/definitions/builtin-metadata.ts +17 -5
  45. package/src/adapters/definitions/native-channel-state.ts +1 -1
  46. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  47. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/kimi-wire-usage.ts +303 -0
  50. package/src/adapters/local-worker.ts +5 -4
  51. package/src/adapters/registry-types.ts +8 -4
  52. package/src/adapters/registry.ts +14 -2
  53. package/src/adapters/reviewer-print.ts +30 -53
  54. package/src/cli/console-config.ts +123 -0
  55. package/src/cli/console-state.ts +48 -3
  56. package/src/cli/console-symbols.ts +3 -2
  57. package/src/cli/console-usage.ts +5 -2
  58. package/src/cli/console.ts +30 -2
  59. package/src/cli/facts-hook.ts +9 -5
  60. package/src/cli/help.ts +10 -7
  61. package/src/cli/identity.ts +3 -1
  62. package/src/cli/launch.ts +10 -4
  63. package/src/cli/main.ts +91 -19
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +40 -4
  66. package/src/cli/reset.ts +1 -1
  67. package/src/cli/settings-edit.ts +89 -0
  68. package/src/cli/terminal-recovery.ts +1 -6
  69. package/src/hub/action-class.ts +211 -17
  70. package/src/hub/action-enforce.ts +90 -0
  71. package/src/hub/action-policy.ts +40 -17
  72. package/src/hub/ask.ts +16 -4
  73. package/src/hub/board.ts +110 -31
  74. package/src/hub/budget-notes.ts +65 -0
  75. package/src/hub/budget.ts +44 -7
  76. package/src/hub/bus.ts +6 -6
  77. package/src/hub/claude-launch-proof.ts +33 -0
  78. package/src/hub/code-facts.ts +56 -16
  79. package/src/hub/conductor.ts +5 -2
  80. package/src/hub/config-apply.ts +12 -0
  81. package/src/hub/config-edit-host.ts +173 -0
  82. package/src/hub/config-edit.ts +282 -0
  83. package/src/hub/config-tier.ts +89 -0
  84. package/src/hub/control-client.ts +24 -3
  85. package/src/hub/daemon.ts +614 -104
  86. package/src/hub/deliverable-files.ts +131 -0
  87. package/src/hub/deliverables.ts +125 -0
  88. package/src/hub/delivery-journal.ts +18 -2
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +5 -3
  91. package/src/hub/hub-tools.ts +22 -6
  92. package/src/hub/instances.ts +7 -4
  93. package/src/hub/kimi-usage.ts +156 -0
  94. package/src/hub/lifecycle.ts +10 -5
  95. package/src/hub/manager.ts +1 -1
  96. package/src/hub/memory-code-head.ts +44 -0
  97. package/src/hub/memory-code-overlay.ts +119 -0
  98. package/src/hub/memory-plan-guard.ts +10 -1
  99. package/src/hub/memory-stale-anchors.ts +159 -0
  100. package/src/hub/memory-symbols.ts +74 -0
  101. package/src/hub/peers.ts +7 -0
  102. package/src/hub/permission-payload.ts +15 -0
  103. package/src/hub/planning.ts +10 -4
  104. package/src/hub/read-tools.ts +21 -3
  105. package/src/hub/routing.ts +118 -3
  106. package/src/hub/sdlc.ts +34 -0
  107. package/src/hub/settings.ts +18 -3
  108. package/src/hub/settled-roles.ts +15 -3
  109. package/src/hub/task-sweep.ts +25 -3
  110. package/src/hub/tasks.ts +551 -75
  111. package/src/hub/token-window.ts +46 -0
  112. package/src/hub/usage-rollup.ts +16 -6
  113. package/src/hub/window-kind.ts +13 -0
  114. package/src/hub/workflow.ts +16 -0
  115. package/src/local/tools.ts +5 -5
  116. package/src/memory/brief.ts +22 -12
  117. package/src/memory/capture.ts +26 -40
  118. package/src/memory/client.ts +137 -65
  119. package/src/memory/recall.ts +13 -22
  120. 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
- // claude-mem's observer is a cloud model: nothing of a PII turn is captured.
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
  }
@@ -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]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
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
- return variant && kind !== id && reviewerOnly && ownField(declaration, "start_mode") === "headless" && fresh ? variant : definition;
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 { spawn, type ChildProcess } from "node:child_process";
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: ChildProcess | undefined;
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 GroupCleanupError) {
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: ChildProcess | undefined, releasing: Promise<void> | undefined;
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
- return await new Promise<{ code: number | null; quota: boolean }>((resolve, reject) => {
342
- const child = spawn(cmd!, [...prefix, ...launch!.args], { cwd: launch!.cwd ?? this.opts.cwd, detached: true,
343
- env: peerChildEnv(this.id, launch!.envMode === "replace" ? launch!.env : { ...process.env, ...launch!.env }), stdio: ["pipe", "pipe", "ignore"] });
344
- // Owned before any await, so a stop that lands before "spawn" still finds and kills it.
345
- this.child = child;
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
- child.stdin!.end(input);
359
- });
360
- let buffer = "", outputBytes = 0;
361
- child.stdout!.on("data", (chunk: Buffer) => {
362
- if (generation !== this.generation) return;
363
- outputBytes += chunk.length;
364
- if (outputBytes > MAX_REVIEW_OUTPUT_BYTES) { this.abortReview("review output limit exceeded"); return; }
365
- buffer += chunk.toString("utf8");
366
- for (let i; (i = buffer.indexOf("\n")) >= 0;) {
367
- const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
368
- if (Buffer.byteLength(line) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
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 (this.childStop ? this.childStop(child) : stopOwnedProcess(child, { group: true }));
407
- if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
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