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 +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +127 -33
- package/plugins/immune-brain/runtime/claude/review_host.ts +131 -15
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +20 -4
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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 =
|
|
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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
137
|
+
throw new Error(`internal role prompt is not packaged: ${role}`);
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
export function buildRoleDelegationPacket(input: {
|