immune-brain 3.6.1 → 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.1",
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.1",
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.1";
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
  }
@@ -1001,13 +1088,20 @@ function roleSpec(role) {
1001
1088
  throw new Error(`unknown internal role: ${String(role)}`);
1002
1089
  return spec;
1003
1090
  }
1091
+ function rolePromptSearchDirs(moduleDir) {
1092
+ return [
1093
+ join3(moduleDir, "..", "dist", "role-prompts"),
1094
+ join3(moduleDir, "..", "role-prompts")
1095
+ ];
1096
+ }
1004
1097
  function loadRolePrompt(role) {
1005
1098
  const spec = roleSpec(role);
1006
- const path = join3(RUNTIME_DIR, "..", "dist", "role-prompts", spec.file);
1007
- if (!existsSync(path)) {
1008
- throw new Error(`internal role prompt is not packaged: ${role}`);
1099
+ for (const dir of rolePromptSearchDirs(RUNTIME_DIR)) {
1100
+ const path = join3(dir, spec.file);
1101
+ if (existsSync(path))
1102
+ return readFileSync(path, "utf8");
1009
1103
  }
1010
- return readFileSync(path, "utf8");
1104
+ throw new Error(`internal role prompt is not packaged: ${role}`);
1011
1105
  }
1012
1106
  function buildRoleDelegationPacket(input) {
1013
1107
  const spec = roleSpec(input.role);
@@ -1703,7 +1797,7 @@ import {
1703
1797
  writeFileSync,
1704
1798
  statSync as statSync2,
1705
1799
  readFileSync as readFileSync3,
1706
- realpathSync as realpathSync3,
1800
+ realpathSync as realpathSync4,
1707
1801
  chmodSync
1708
1802
  } from "node:fs";
1709
1803
  import { tmpdir as tmpdir2 } from "node:os";
@@ -1717,7 +1811,7 @@ import {
1717
1811
  lstatSync as lstatSync2,
1718
1812
  readFileSync as readFileSync2,
1719
1813
  readlinkSync,
1720
- realpathSync as realpathSync2
1814
+ realpathSync as realpathSync3
1721
1815
  } from "node:fs";
1722
1816
  import { resolve as resolve2 } from "node:path";
1723
1817
  function git(root, args) {
@@ -1884,7 +1978,7 @@ function taskSnapshotOnce(root, scope) {
1884
1978
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
1885
1979
  if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
1886
1980
  throw new Error("cannot derive task snapshot outside a committed Git workspace");
1887
- if (realpathSync2(resolve2(repositoryRoot)) !== root)
1981
+ if (realpathSync3(resolve2(repositoryRoot)) !== root)
1888
1982
  throw new Error("task snapshot repository root does not match the project root");
1889
1983
  const sparseCheckout = git(root, ["config", "--bool", "core.sparseCheckout"])?.trim();
1890
1984
  const sparseIndex = git(root, ["config", "--bool", "index.sparse"])?.trim();
@@ -1928,7 +2022,7 @@ function captureGitTaskSnapshot(projectRoot, scopeHint) {
1928
2022
  const requestedStat = lstatSync2(requestedRoot);
1929
2023
  if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
1930
2024
  throw new Error("task snapshot root must be a real directory");
1931
- const root = realpathSync2(requestedRoot);
2025
+ const root = realpathSync3(requestedRoot);
1932
2026
  const scope = assertCanonicalTaskScope(scopeHint);
1933
2027
  const before = taskSnapshotOnce(root, scope);
1934
2028
  gitTaskSnapshotTestHook?.();
@@ -1961,7 +2055,7 @@ function taskRevisionSnapshotOnce(root, scope, baseHead) {
1961
2055
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
1962
2056
  if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
1963
2057
  throw new Error("cannot derive a task revision outside a committed Git workspace");
1964
- if (realpathSync2(resolve2(repositoryRoot)) !== root)
2058
+ if (realpathSync3(resolve2(repositoryRoot)) !== root)
1965
2059
  throw new Error("task revision repository root does not match the project root");
1966
2060
  if (gitRequired(root, ["cat-file", "-t", baseHead], `task revision base is unreadable: ${baseHead}`) !== "commit")
1967
2061
  throw new Error(`task revision base is not a commit: ${baseHead}`);
@@ -2018,7 +2112,7 @@ function captureGitTaskRevisionSnapshot(projectRoot, scopeHint, baseHead) {
2018
2112
  const requestedStat = lstatSync2(requestedRoot);
2019
2113
  if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
2020
2114
  throw new Error("task revision root must be a real directory");
2021
- const root = realpathSync2(requestedRoot);
2115
+ const root = realpathSync3(requestedRoot);
2022
2116
  if (typeof baseHead !== "string" || !GIT_OBJECT_ID.test(baseHead.toLowerCase()))
2023
2117
  throw new Error("task revision base must be a Git commit id");
2024
2118
  const scope = assertCanonicalTaskScope(scopeHint);
@@ -2396,7 +2490,7 @@ function ensureReviewRevision(root, input) {
2396
2490
  function writeNativeReviewEvidence(payload) {
2397
2491
  const rawDirectory = mkdtempSync(join4(tmpdir2(), "imm-canary-native-review-"));
2398
2492
  try {
2399
- const directory = realpathSync3(rawDirectory);
2493
+ const directory = realpathSync4(rawDirectory);
2400
2494
  chmodSync(directory, 493);
2401
2495
  const path = join4(directory, "evidence.json");
2402
2496
  writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 420, flag: "wx" });
@@ -2411,7 +2505,7 @@ function writeNativeReviewEvidence(payload) {
2411
2505
  }
2412
2506
  }
2413
2507
  function assertReviewArtifact(path) {
2414
- const targetPath = realpathSync3(path);
2508
+ const targetPath = realpathSync4(path);
2415
2509
  let stat;
2416
2510
  try {
2417
2511
  stat = statSync2(targetPath);
@@ -2605,7 +2699,7 @@ import {
2605
2699
  mkdirSync as mkdirSync2,
2606
2700
  openSync as openSync3,
2607
2701
  readFileSync as readFileSync6,
2608
- realpathSync as realpathSync5,
2702
+ realpathSync as realpathSync6,
2609
2703
  renameSync,
2610
2704
  rmSync as rmSync3,
2611
2705
  writeFileSync as writeFileSync2
@@ -2634,7 +2728,7 @@ import {
2634
2728
  lstatSync as lstatSync4,
2635
2729
  openSync as openSync2,
2636
2730
  readFileSync as readFileSync5,
2637
- realpathSync as realpathSync4
2731
+ realpathSync as realpathSync5
2638
2732
  } from "node:fs";
2639
2733
  import { execFileSync as execFileSync3 } from "node:child_process";
2640
2734
  import { join as join6, resolve as resolve4, sep as sep3 } from "node:path";
@@ -2932,7 +3026,7 @@ function resolveCanonicalRoot(root) {
2932
3026
  const rootStat = lstatSync4(resolved);
2933
3027
  if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
2934
3028
  throw new Error("project root must be a real directory, not a symlink");
2935
- return realpathSync4(resolved);
3029
+ return realpathSync5(resolved);
2936
3030
  }
2937
3031
  function resolveSidecarPath(canonicalRoot, activePath, archivedPath) {
2938
3032
  if (sidecarPresent(canonicalRoot, activePath))
@@ -3009,7 +3103,7 @@ function readTaskIntent(root, taskId, requestedPath) {
3009
3103
  const after = lstatSync4(target);
3010
3104
  assertSameIdentity(statIdentity(before), after, "intent sidecar");
3011
3105
  assertIdentitiesUnchanged(pathIdentities, canonicalRoot, sidecarPath);
3012
- const canonicalAgain = realpathSync4(root);
3106
+ const canonicalAgain = realpathSync5(root);
3013
3107
  if (canonicalAgain !== canonicalRoot)
3014
3108
  throw new Error("canonical project root drifted while being read");
3015
3109
  if (lstatSync4(canonicalAgain).isSymbolicLink())
@@ -3752,7 +3846,7 @@ function revisionFor(content) {
3752
3846
  }
3753
3847
  function canonicalRoot(root) {
3754
3848
  try {
3755
- return realpathSync5(root);
3849
+ return realpathSync6(root);
3756
3850
  } catch {
3757
3851
  throw new KernelStoreSecurityError("project root is unavailable");
3758
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.1" as const;
2
+ export const PLUGIN_VERSION = "3.6.3" as const;
@@ -108,17 +108,33 @@ function roleSpec(role: InternalRole): RolePromptSpec {
108
108
  return spec;
109
109
  }
110
110
 
111
+ /**
112
+ * Directories that can hold the packaged role prompts, most specific first.
113
+ *
114
+ * From source this module sits in `runtime/`, a sibling of `dist/`. The Claude
115
+ * Code Host instead ships a bundle at `dist/claude/mcp-server.mjs`, where the
116
+ * same relative walk lands on a `dist/dist/` that never exists while the prompts
117
+ * sit one level up. Every test runs from source, so the shipped Host could not
118
+ * load a single internal role prompt and no test noticed.
119
+ */
120
+ export function rolePromptSearchDirs(moduleDir: string): string[] {
121
+ return [
122
+ join(moduleDir, "..", "dist", "role-prompts"),
123
+ join(moduleDir, "..", "role-prompts"),
124
+ ];
125
+ }
126
+
111
127
  /**
112
128
  * Read the packaged prompt so the runtime follows the bytes shipped to a
113
129
  * consumer. The canonical source is synced into this dist-local directory.
114
130
  */
115
131
  export function loadRolePrompt(role: InternalRole): string {
116
132
  const spec = roleSpec(role);
117
- const path = join(RUNTIME_DIR, "..", "dist", "role-prompts", spec.file);
118
- if (!existsSync(path)) {
119
- throw new Error(`internal role prompt is not packaged: ${role}`);
133
+ for (const dir of rolePromptSearchDirs(RUNTIME_DIR)) {
134
+ const path = join(dir, spec.file);
135
+ if (existsSync(path)) return readFileSync(path, "utf8");
120
136
  }
121
- return readFileSync(path, "utf8");
137
+ throw new Error(`internal role prompt is not packaged: ${role}`);
122
138
  }
123
139
 
124
140
  export function buildRoleDelegationPacket(input: {