immune-brain 3.6.2 → 3.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.2",
3
+ "version": "3.6.3",
4
4
  "description": "Immune-Brain agent skill system",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.2",
3
+ "version": "3.6.3",
4
4
  "description": "Immune-Brain Claude Code Host: native Enrollment, QA, Review, and Kernel settlement.",
5
5
  "author": {
6
6
  "name": "Immune-Brain Team"
@@ -42,7 +42,7 @@ function probeHost(env = process.env, platform = process.platform, hostVersion)
42
42
  }
43
43
 
44
44
  // plugins/immune-brain/runtime/plugin_version.ts
45
- var PLUGIN_VERSION = "3.6.2";
45
+ var PLUGIN_VERSION = "3.6.3";
46
46
 
47
47
  // plugins/immune-brain/runtime/claude/interaction.ts
48
48
  import { createHash, randomUUID } from "node:crypto";
@@ -99,7 +99,7 @@ function enrollmentNonce() {
99
99
 
100
100
  // plugins/immune-brain/runtime/claude/review_host.ts
101
101
  import { createHash as createHash2 } from "node:crypto";
102
- import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, writeSync, closeSync } from "node:fs";
102
+ import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, closeSync } from "node:fs";
103
103
  import { dirname, join } from "node:path";
104
104
  import { tmpdir } from "node:os";
105
105
  var REVIEWER_AGENT = "immune-brain-reviewer";
@@ -183,7 +183,7 @@ function appendPrivate(path, dir, line) {
183
183
  closeSync(fd);
184
184
  }
185
185
  }
186
- function readPrivate(path) {
186
+ function readPrivate(path, maxBytes = Number.POSITIVE_INFINITY) {
187
187
  let fd;
188
188
  try {
189
189
  fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
@@ -194,6 +194,8 @@ function readPrivate(path) {
194
194
  const stat = fstatSync(fd);
195
195
  if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 511) !== 384)
196
196
  return;
197
+ if (stat.size > maxBytes)
198
+ return;
197
199
  const buf = Buffer.alloc(stat.size);
198
200
  readSync(fd, buf, 0, stat.size, 0);
199
201
  return buf.toString("utf8");
@@ -201,6 +203,67 @@ function readPrivate(path) {
201
203
  closeSync(fd);
202
204
  }
203
205
  }
206
+ var MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
207
+ function parseAsyncAgentLaunch(result) {
208
+ let payload;
209
+ try {
210
+ payload = JSON.parse(result);
211
+ } catch {
212
+ return null;
213
+ }
214
+ if (!payload || typeof payload !== "object")
215
+ return null;
216
+ const obj = payload;
217
+ if (obj.isAsync !== true && obj.status !== "async_launched")
218
+ return null;
219
+ const agentId = typeof obj.agentId === "string" ? obj.agentId : "";
220
+ const outputFile = typeof obj.outputFile === "string" ? obj.outputFile : "";
221
+ if (!agentId || !outputFile)
222
+ return null;
223
+ return { agentId, outputFile };
224
+ }
225
+ function readAgentTranscriptResult(transcript, agentId) {
226
+ let last = null;
227
+ for (const line of transcript.split(`
228
+ `)) {
229
+ if (!line.trim())
230
+ continue;
231
+ let row;
232
+ try {
233
+ row = JSON.parse(line);
234
+ } catch {
235
+ continue;
236
+ }
237
+ if (row.type !== "assistant" || row.agentId !== agentId)
238
+ continue;
239
+ const message = row.message;
240
+ if (!message || typeof message !== "object")
241
+ continue;
242
+ const content = message.content;
243
+ if (!Array.isArray(content))
244
+ continue;
245
+ let text = "";
246
+ for (const block of content) {
247
+ if (!block || typeof block !== "object")
248
+ continue;
249
+ const part = block;
250
+ if (part.type === "text" && typeof part.text === "string")
251
+ text += part.text;
252
+ }
253
+ if (text.trim())
254
+ last = text;
255
+ }
256
+ return last;
257
+ }
258
+ function readAgentTranscript(path) {
259
+ let resolved;
260
+ try {
261
+ resolved = realpathSync(path);
262
+ } catch {
263
+ return;
264
+ }
265
+ return readPrivate(resolved, MAX_TRANSCRIPT_BYTES);
266
+ }
204
267
  function hookEventPath(sessionId, root = tmpdir()) {
205
268
  return join(cacheDir(root), `${sessionHash(sessionId)}.jsonl`);
206
269
  }
@@ -288,6 +351,7 @@ class ClaudeReviewHost {
288
351
  this.log = log;
289
352
  }
290
353
  prepareReview(request) {
354
+ this.drain();
291
355
  const initialCursors = new Map;
292
356
  const sessionCursors = new Map;
293
357
  for (const sessionId of this.log.sessions()) {
@@ -325,15 +389,23 @@ ${request.prompt}`,
325
389
  for (let i = start;i < events.length; i++) {
326
390
  const event = events[i];
327
391
  if (event.type === "SessionEnd") {
328
- this.log.clear(event.sessionId);
329
- ended = true;
330
- this.appliedBySession.delete(event.sessionId);
331
392
  for (const [id, state] of this.pending) {
332
393
  if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
333
394
  this.pending.delete(id);
334
395
  }
335
396
  }
336
- break;
397
+ for (const state of this.pending.values()) {
398
+ const current = state.initialCursors.get(sessionId) ?? 0;
399
+ if (i + 1 > current)
400
+ state.initialCursors.set(sessionId, i + 1);
401
+ }
402
+ if (i === events.length - 1) {
403
+ this.log.clear(event.sessionId);
404
+ ended = true;
405
+ this.appliedBySession.delete(event.sessionId);
406
+ break;
407
+ }
408
+ continue;
337
409
  }
338
410
  for (const state of this.pending.values()) {
339
411
  if (state.consumed || state.error || i < (state.initialCursors.get(sessionId) ?? 0))
@@ -460,13 +532,23 @@ ${request.prompt}`,
460
532
  if (state.startEvent.sessionId !== state.postEvent.sessionId || state.startEvent.sessionId !== state.stopEvent.sessionId || state.startEvent.agentId !== state.postEvent.agentId || state.startEvent.agentId !== state.stopEvent.agentId) {
461
533
  return { ok: false, reason: "foreground Agent terminal event correlation mismatch", release: true };
462
534
  }
463
- return {
464
- ok: true,
465
- receipt: {
466
- actorId: `claude:${state.startEvent.agentId ?? reservation.id}`,
467
- result: state.postEvent.result
468
- }
469
- };
535
+ const actorId = `claude:${state.startEvent.agentId ?? reservation.id}`;
536
+ const launch = parseAsyncAgentLaunch(state.postEvent.result);
537
+ if (!launch) {
538
+ return { ok: true, receipt: { actorId, result: state.postEvent.result } };
539
+ }
540
+ if (launch.agentId !== state.postEvent.agentId) {
541
+ return { ok: false, reason: "async Agent launch envelope names a different agent", release: true };
542
+ }
543
+ const transcript = readAgentTranscript(launch.outputFile);
544
+ if (transcript === undefined) {
545
+ return { ok: false, reason: "async Agent transcript is not readable", release: false };
546
+ }
547
+ const verdict = readAgentTranscriptResult(transcript, launch.agentId);
548
+ if (!verdict?.trim()) {
549
+ return { ok: false, reason: "async Agent transcript carries no reviewer result", release: false };
550
+ }
551
+ return { ok: true, receipt: { actorId, result: verdict } };
470
552
  }
471
553
  consumeReview(reservation) {
472
554
  const result = this.inspectReview(reservation);
@@ -514,6 +596,11 @@ function parseHookStdin(raw) {
514
596
  if (typeof toolInputObj.prompt === "string")
515
597
  prompt = toolInputObj.prompt;
516
598
  }
599
+ if (!prompt && typeof payload.tool_response === "object" && payload.tool_response !== null) {
600
+ const toolResponseObj = payload.tool_response;
601
+ if (typeof toolResponseObj.prompt === "string")
602
+ prompt = toolResponseObj.prompt;
603
+ }
517
604
  let extractedOpId = operationId;
518
605
  let extractedTaskId = taskId;
519
606
  for (const candidate of [payload.input, payload.tool_input, payload.tool_response, payload.toolResponse, payload.response, payload.result]) {
@@ -602,7 +689,7 @@ import { createHash as createHash5, randomUUID as randomUUID2 } from "node:crypt
602
689
  // plugins/immune-brain/runtime/assurance/verification.ts
603
690
  import { createHash as createHash3 } from "node:crypto";
604
691
  import { execFileSync, spawn } from "node:child_process";
605
- import { realpathSync, statSync } from "node:fs";
692
+ import { realpathSync as realpathSync2, statSync } from "node:fs";
606
693
  import { isAbsolute as isAbsolute2, resolve, sep as sep2, relative } from "node:path";
607
694
 
608
695
  // plugins/immune-brain/runtime/verification_descriptor.ts
@@ -705,7 +792,7 @@ function resolveBunRunner() {
705
792
  }
706
793
  let real;
707
794
  try {
708
- real = realpathSync(executable);
795
+ real = realpathSync2(executable);
709
796
  } catch {
710
797
  throw new VerificationDescriptorError("bun runner realpath is unresolvable");
711
798
  }
@@ -1710,7 +1797,7 @@ import {
1710
1797
  writeFileSync,
1711
1798
  statSync as statSync2,
1712
1799
  readFileSync as readFileSync3,
1713
- realpathSync as realpathSync3,
1800
+ realpathSync as realpathSync4,
1714
1801
  chmodSync
1715
1802
  } from "node:fs";
1716
1803
  import { tmpdir as tmpdir2 } from "node:os";
@@ -1724,7 +1811,7 @@ import {
1724
1811
  lstatSync as lstatSync2,
1725
1812
  readFileSync as readFileSync2,
1726
1813
  readlinkSync,
1727
- realpathSync as realpathSync2
1814
+ realpathSync as realpathSync3
1728
1815
  } from "node:fs";
1729
1816
  import { resolve as resolve2 } from "node:path";
1730
1817
  function git(root, args) {
@@ -1891,7 +1978,7 @@ function taskSnapshotOnce(root, scope) {
1891
1978
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
1892
1979
  if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
1893
1980
  throw new Error("cannot derive task snapshot outside a committed Git workspace");
1894
- if (realpathSync2(resolve2(repositoryRoot)) !== root)
1981
+ if (realpathSync3(resolve2(repositoryRoot)) !== root)
1895
1982
  throw new Error("task snapshot repository root does not match the project root");
1896
1983
  const sparseCheckout = git(root, ["config", "--bool", "core.sparseCheckout"])?.trim();
1897
1984
  const sparseIndex = git(root, ["config", "--bool", "index.sparse"])?.trim();
@@ -1935,7 +2022,7 @@ function captureGitTaskSnapshot(projectRoot, scopeHint) {
1935
2022
  const requestedStat = lstatSync2(requestedRoot);
1936
2023
  if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
1937
2024
  throw new Error("task snapshot root must be a real directory");
1938
- const root = realpathSync2(requestedRoot);
2025
+ const root = realpathSync3(requestedRoot);
1939
2026
  const scope = assertCanonicalTaskScope(scopeHint);
1940
2027
  const before = taskSnapshotOnce(root, scope);
1941
2028
  gitTaskSnapshotTestHook?.();
@@ -1968,7 +2055,7 @@ function taskRevisionSnapshotOnce(root, scope, baseHead) {
1968
2055
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
1969
2056
  if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
1970
2057
  throw new Error("cannot derive a task revision outside a committed Git workspace");
1971
- if (realpathSync2(resolve2(repositoryRoot)) !== root)
2058
+ if (realpathSync3(resolve2(repositoryRoot)) !== root)
1972
2059
  throw new Error("task revision repository root does not match the project root");
1973
2060
  if (gitRequired(root, ["cat-file", "-t", baseHead], `task revision base is unreadable: ${baseHead}`) !== "commit")
1974
2061
  throw new Error(`task revision base is not a commit: ${baseHead}`);
@@ -2025,7 +2112,7 @@ function captureGitTaskRevisionSnapshot(projectRoot, scopeHint, baseHead) {
2025
2112
  const requestedStat = lstatSync2(requestedRoot);
2026
2113
  if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
2027
2114
  throw new Error("task revision root must be a real directory");
2028
- const root = realpathSync2(requestedRoot);
2115
+ const root = realpathSync3(requestedRoot);
2029
2116
  if (typeof baseHead !== "string" || !GIT_OBJECT_ID.test(baseHead.toLowerCase()))
2030
2117
  throw new Error("task revision base must be a Git commit id");
2031
2118
  const scope = assertCanonicalTaskScope(scopeHint);
@@ -2403,7 +2490,7 @@ function ensureReviewRevision(root, input) {
2403
2490
  function writeNativeReviewEvidence(payload) {
2404
2491
  const rawDirectory = mkdtempSync(join4(tmpdir2(), "imm-canary-native-review-"));
2405
2492
  try {
2406
- const directory = realpathSync3(rawDirectory);
2493
+ const directory = realpathSync4(rawDirectory);
2407
2494
  chmodSync(directory, 493);
2408
2495
  const path = join4(directory, "evidence.json");
2409
2496
  writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 420, flag: "wx" });
@@ -2418,7 +2505,7 @@ function writeNativeReviewEvidence(payload) {
2418
2505
  }
2419
2506
  }
2420
2507
  function assertReviewArtifact(path) {
2421
- const targetPath = realpathSync3(path);
2508
+ const targetPath = realpathSync4(path);
2422
2509
  let stat;
2423
2510
  try {
2424
2511
  stat = statSync2(targetPath);
@@ -2612,7 +2699,7 @@ import {
2612
2699
  mkdirSync as mkdirSync2,
2613
2700
  openSync as openSync3,
2614
2701
  readFileSync as readFileSync6,
2615
- realpathSync as realpathSync5,
2702
+ realpathSync as realpathSync6,
2616
2703
  renameSync,
2617
2704
  rmSync as rmSync3,
2618
2705
  writeFileSync as writeFileSync2
@@ -2641,7 +2728,7 @@ import {
2641
2728
  lstatSync as lstatSync4,
2642
2729
  openSync as openSync2,
2643
2730
  readFileSync as readFileSync5,
2644
- realpathSync as realpathSync4
2731
+ realpathSync as realpathSync5
2645
2732
  } from "node:fs";
2646
2733
  import { execFileSync as execFileSync3 } from "node:child_process";
2647
2734
  import { join as join6, resolve as resolve4, sep as sep3 } from "node:path";
@@ -2939,7 +3026,7 @@ function resolveCanonicalRoot(root) {
2939
3026
  const rootStat = lstatSync4(resolved);
2940
3027
  if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
2941
3028
  throw new Error("project root must be a real directory, not a symlink");
2942
- return realpathSync4(resolved);
3029
+ return realpathSync5(resolved);
2943
3030
  }
2944
3031
  function resolveSidecarPath(canonicalRoot, activePath, archivedPath) {
2945
3032
  if (sidecarPresent(canonicalRoot, activePath))
@@ -3016,7 +3103,7 @@ function readTaskIntent(root, taskId, requestedPath) {
3016
3103
  const after = lstatSync4(target);
3017
3104
  assertSameIdentity(statIdentity(before), after, "intent sidecar");
3018
3105
  assertIdentitiesUnchanged(pathIdentities, canonicalRoot, sidecarPath);
3019
- const canonicalAgain = realpathSync4(root);
3106
+ const canonicalAgain = realpathSync5(root);
3020
3107
  if (canonicalAgain !== canonicalRoot)
3021
3108
  throw new Error("canonical project root drifted while being read");
3022
3109
  if (lstatSync4(canonicalAgain).isSymbolicLink())
@@ -3759,7 +3846,7 @@ function revisionFor(content) {
3759
3846
  }
3760
3847
  function canonicalRoot(root) {
3761
3848
  try {
3762
- return realpathSync5(root);
3849
+ return realpathSync6(root);
3763
3850
  } catch {
3764
3851
  throw new KernelStoreSecurityError("project root is unavailable");
3765
3852
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, writeSync, closeSync } from "node:fs";
2
+ import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, closeSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import type {
@@ -122,12 +122,13 @@ function appendPrivate(path: string, dir: string, line: string): boolean {
122
122
  }
123
123
  }
124
124
 
125
- function readPrivate(path: string): string | undefined {
125
+ function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
126
126
  let fd: number;
127
127
  try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
128
128
  try {
129
129
  const stat = fstatSync(fd);
130
130
  if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return;
131
+ if (stat.size > maxBytes) return;
131
132
  const buf = Buffer.alloc(stat.size);
132
133
  readSync(fd, buf, 0, stat.size, 0);
133
134
  return buf.toString("utf8");
@@ -136,6 +137,78 @@ function readPrivate(path: string): string | undefined {
136
137
  }
137
138
  }
138
139
 
140
+ /**
141
+ * Upper bound on a reviewer transcript we are willing to load. A reviewer that
142
+ * produced more than this did not produce a verdict; refusing to allocate for it
143
+ * is safer than trusting whatever the tail happens to contain.
144
+ */
145
+ const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
146
+
147
+ export type AsyncAgentLaunch = { agentId: string; outputFile: string };
148
+
149
+ /**
150
+ * Recognise the launch receipt this Claude Code build returns for `Agent`.
151
+ *
152
+ * Every `Agent` call here runs asynchronously — `run_in_background: false` in
153
+ * the dispatch envelope is not honoured, and there is no synchronous mode. The
154
+ * tool result is therefore
155
+ * `{"isAsync":true,"status":"async_launched","agentId":…,"outputFile":…}`:
156
+ * proof that a subagent started, never its answer. Treating it as the verdict
157
+ * would settle Review on a receipt for starting the reviewer, so the launch
158
+ * envelope is read only as a pointer to where the real bytes live.
159
+ */
160
+ export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
161
+ let payload: unknown;
162
+ try { payload = JSON.parse(result); } catch { return null; }
163
+ if (!payload || typeof payload !== "object") return null;
164
+ const obj = payload as Record<string, unknown>;
165
+ if (obj.isAsync !== true && obj.status !== "async_launched") return null;
166
+ const agentId = typeof obj.agentId === "string" ? obj.agentId : "";
167
+ const outputFile = typeof obj.outputFile === "string" ? obj.outputFile : "";
168
+ if (!agentId || !outputFile) return null;
169
+ return { agentId, outputFile };
170
+ }
171
+
172
+ /**
173
+ * Extract the reviewer's terminal message from its own transcript.
174
+ *
175
+ * Each record carries the `agentId` that wrote it, so the caller's independently
176
+ * observed id is matched per record rather than trusted for the file as a whole;
177
+ * a transcript that interleaves another agent cannot contribute its text.
178
+ */
179
+ export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
180
+ let last: string | null = null;
181
+ for (const line of transcript.split("\n")) {
182
+ if (!line.trim()) continue;
183
+ let row: Record<string, unknown>;
184
+ try { row = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
185
+ if (row.type !== "assistant" || row.agentId !== agentId) continue;
186
+ const message = row.message;
187
+ if (!message || typeof message !== "object") continue;
188
+ const content = (message as Record<string, unknown>).content;
189
+ if (!Array.isArray(content)) continue;
190
+ let text = "";
191
+ for (const block of content) {
192
+ if (!block || typeof block !== "object") continue;
193
+ const part = block as Record<string, unknown>;
194
+ if (part.type === "text" && typeof part.text === "string") text += part.text;
195
+ }
196
+ if (text.trim()) last = text;
197
+ }
198
+ return last;
199
+ }
200
+
201
+ /**
202
+ * Read the transcript the launch envelope names. The path is a symlink into the
203
+ * session store, so it is resolved once and then opened `O_NOFOLLOW` with the
204
+ * same ownership and mode checks the hook log gets.
205
+ */
206
+ function readAgentTranscript(path: string): string | undefined {
207
+ let resolved: string;
208
+ try { resolved = realpathSync(path); } catch { return; }
209
+ return readPrivate(resolved, MAX_TRANSCRIPT_BYTES);
210
+ }
211
+
139
212
  export function hookEventPath(sessionId: string, root = tmpdir()): string {
140
213
  return join(cacheDir(root), `${sessionHash(sessionId)}.jsonl`);
141
214
  }
@@ -229,6 +302,11 @@ export class ClaudeReviewHost implements AssuranceHostPort {
229
302
  constructor(private readonly log: HookEventLog = new MemoryHookEventLog()) {}
230
303
 
231
304
  prepareReview(request: ReviewRequest): HostReviewReservation {
305
+ // Retire whatever the log already holds before the cursors are taken. A
306
+ // resumed session inherits its predecessor's file, and an unprocessed
307
+ // `SessionEnd` sitting in it used to survive until the first inspection —
308
+ // long after this reservation's own events had been appended behind it.
309
+ this.drain();
232
310
  const initialCursors = new Map<string, number>();
233
311
  const sessionCursors = new Map<string, number>();
234
312
  for (const sessionId of this.log.sessions()) {
@@ -266,17 +344,31 @@ export class ClaudeReviewHost implements AssuranceHostPort {
266
344
  for (let i = start; i < events.length; i++) {
267
345
  const event = events[i];
268
346
  if (event.type === "SessionEnd") {
269
- this.log.clear(event.sessionId);
270
- ended = true;
271
- this.appliedBySession.delete(event.sessionId);
347
+ // Any reservation that already bound evidence from the finished
348
+ // session loses it outright; that evidence can never be revived.
272
349
  for (const [id, state] of this.pending) {
273
350
  if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
274
351
  this.pending.delete(id);
275
352
  }
276
353
  }
277
- // The session ended: stop processing this captured batch so a
278
- // later event cannot repopulate confirmations after cleanup.
279
- break;
354
+ // A resumed session reuses its id, so the same log can hold events
355
+ // appended after an earlier end. Advancing every surviving cursor
356
+ // past this point keeps the pre-end events unusable — the property
357
+ // the old whole-log clear was protecting — while still letting the
358
+ // events that follow reach their reservation. Clearing and stopping
359
+ // here instead silently discarded a live Review receipt.
360
+ for (const state of this.pending.values()) {
361
+ const current = state.initialCursors.get(sessionId) ?? 0;
362
+ if (i + 1 > current) state.initialCursors.set(sessionId, i + 1);
363
+ }
364
+ if (i === events.length - 1) {
365
+ // Nothing followed the end, so the log is safe to reclaim.
366
+ this.log.clear(event.sessionId);
367
+ ended = true;
368
+ this.appliedBySession.delete(event.sessionId);
369
+ break;
370
+ }
371
+ continue;
280
372
  }
281
373
  for (const state of this.pending.values()) {
282
374
  if (state.consumed || state.error || i < (state.initialCursors.get(sessionId) ?? 0)) continue;
@@ -387,13 +479,29 @@ export class ClaudeReviewHost implements AssuranceHostPort {
387
479
  ) {
388
480
  return { ok: false, reason: "foreground Agent terminal event correlation mismatch", release: true };
389
481
  }
390
- return {
391
- ok: true,
392
- receipt: {
393
- actorId: `claude:${state.startEvent.agentId ?? reservation.id}`,
394
- result: state.postEvent.result,
395
- },
396
- };
482
+ const actorId = `claude:${state.startEvent.agentId ?? reservation.id}`;
483
+ const launch = parseAsyncAgentLaunch(state.postEvent.result);
484
+ if (!launch) {
485
+ return { ok: true, receipt: { actorId, result: state.postEvent.result } };
486
+ }
487
+ // Invariant guard. Correlation already binds the PostToolUse through the
488
+ // same `agentId` this envelope carries, so a foreign id normally never
489
+ // reaches here; assert it anyway rather than read a transcript the three
490
+ // observed events did not agree on.
491
+ if (launch.agentId !== state.postEvent.agentId) {
492
+ return { ok: false, reason: "async Agent launch envelope names a different agent", release: true };
493
+ }
494
+ const transcript = readAgentTranscript(launch.outputFile);
495
+ if (transcript === undefined) {
496
+ // Fail closed rather than falling back to bytes the Parent supplied:
497
+ // an optional weaker path is a path the Parent can choose to force.
498
+ return { ok: false, reason: "async Agent transcript is not readable", release: false };
499
+ }
500
+ const verdict = readAgentTranscriptResult(transcript, launch.agentId);
501
+ if (!verdict?.trim()) {
502
+ return { ok: false, reason: "async Agent transcript carries no reviewer result", release: false };
503
+ }
504
+ return { ok: true, receipt: { actorId, result: verdict } };
397
505
  }
398
506
 
399
507
  consumeReview(reservation: HostReviewReservation): ConsumeReviewResult {
@@ -437,6 +545,14 @@ export function parseHookStdin(raw: string): ClaudeHookEvent | null {
437
545
  const toolInputObj = payload.tool_input as Record<string, unknown>;
438
546
  if (typeof toolInputObj.prompt === "string") prompt = toolInputObj.prompt;
439
547
  }
548
+ // The async launch envelope echoes the dispatched prompt. It is the only
549
+ // place the reservation marker appears when a payload omits `tool_input`,
550
+ // and it is written by the Host, not by the Parent, like every other field
551
+ // read here.
552
+ if (!prompt && typeof payload.tool_response === "object" && payload.tool_response !== null) {
553
+ const toolResponseObj = payload.tool_response as Record<string, unknown>;
554
+ if (typeof toolResponseObj.prompt === "string") prompt = toolResponseObj.prompt;
555
+ }
440
556
  let extractedOpId = operationId;
441
557
  let extractedTaskId = taskId;
442
558
  // Extract nested operation_id/task_id from tool_input or tool_response (e.g. { tool_input: { operation_id, task_id } })
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "3.6.2" as const;
2
+ export const PLUGIN_VERSION = "3.6.3" as const;