@letta-ai/letta-code 0.30.26 → 0.30.28

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 (55) hide show
  1. package/dist/agent-presets.js +17 -17
  2. package/dist/agent-presets.js.map +1 -1
  3. package/dist/mcp-client.js +2 -2
  4. package/dist/mcp-client.js.map +1 -1
  5. package/dist/types/agent/turn-recovery-policy.d.ts +33 -0
  6. package/dist/types/agent/turn-recovery-policy.d.ts.map +1 -1
  7. package/dist/types/tools/impl/apply-patch.d.ts.map +1 -1
  8. package/dist/types/tools/secret-substitution.d.ts.map +1 -1
  9. package/dist/types/types/loop-status-protocol.d.ts +17 -0
  10. package/dist/types/types/loop-status-protocol.d.ts.map +1 -0
  11. package/dist/types/types/protocol_v2.d.ts +2 -19
  12. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  13. package/dist/types/websocket/listener/inbound-queue.d.ts +5 -0
  14. package/dist/types/websocket/listener/inbound-queue.d.ts.map +1 -0
  15. package/dist/types/websocket/listener/protocol-outbound-routing.d.ts +9 -0
  16. package/dist/types/websocket/listener/protocol-outbound-routing.d.ts.map +1 -0
  17. package/dist/types/websocket/listener/protocol-outbound.d.ts.map +1 -1
  18. package/dist/types/websocket/listener/runtime.d.ts.map +1 -1
  19. package/dist/types/websocket/listener/turn-correlation.d.ts +10 -0
  20. package/dist/types/websocket/listener/turn-correlation.d.ts.map +1 -0
  21. package/dist/types/websocket/listener/types.d.ts +4 -0
  22. package/dist/types/websocket/listener/types.d.ts.map +1 -1
  23. package/letta.js +545 -120
  24. package/package.json +1 -1
  25. package/scripts/claude-watch/agent-watch.ts +622 -0
  26. package/scripts/claude-watch/docs-snapshot.test.ts +259 -0
  27. package/scripts/claude-watch/docs-snapshot.ts +672 -0
  28. package/scripts/claude-watch/fixtures/historical-replays.json +52 -0
  29. package/scripts/claude-watch/github.ts +137 -0
  30. package/scripts/claude-watch/release-analysis.test.ts +235 -0
  31. package/scripts/claude-watch/release-analysis.ts +297 -0
  32. package/scripts/claude-watch/release-source.test.ts +179 -0
  33. package/scripts/claude-watch/release-source.ts +369 -0
  34. package/scripts/claude-watch/runtime-observations.ts +98 -0
  35. package/scripts/claude-watch/runtime-probe.test.ts +576 -0
  36. package/scripts/claude-watch/runtime-probe.ts +911 -0
  37. package/scripts/claude-watch/runtime-sandbox.ts +170 -0
  38. package/scripts/claude-watch/state-branch.test.ts +211 -0
  39. package/scripts/claude-watch/state-branch.ts +316 -0
  40. package/scripts/claude-watch/tracker.test.ts +148 -0
  41. package/scripts/claude-watch/tracker.ts +325 -0
  42. package/scripts/claude-watch/types.ts +186 -0
  43. package/scripts/claude-watch/update-tracker.ts +201 -0
  44. package/scripts/codex-watch/agent-watch.ts +2 -2
  45. package/scripts/codex-watch/release-analysis.ts +14 -2
  46. package/scripts/codex-watch/tracker.ts +1 -3
  47. package/scripts/run-unit-tests.cjs +2 -0
  48. package/scripts/source-file-size-baseline.json +6 -5
  49. package/skills/creating-mods/references/commands.md +1 -1
  50. package/skills/creating-mods/references/ui.md +1 -1
  51. package/skills/customizing-commands/SKILL.md +1 -1
  52. package/skills/initializing-memory/SKILL.md +7 -7
  53. package/skills/self-configuration/SKILL.md +4 -4
  54. package/scripts/codex-watch/check-release.ts +0 -128
  55. package/scripts/codex-watch/render-issue.ts +0 -273
@@ -0,0 +1,911 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ readdir,
6
+ readFile,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { join, relative, resolve } from "node:path";
12
+ import { evaluateProbe } from "./runtime-observations.ts";
13
+ import {
14
+ type CommandRunner,
15
+ type CommandSpec,
16
+ runBoundedCommand,
17
+ sandboxClaudeCommand,
18
+ } from "./runtime-sandbox.ts";
19
+ import type {
20
+ ClaudeProbeObservation,
21
+ ClaudeRuntimeDiff,
22
+ ClaudeRuntimeSnapshot,
23
+ } from "./types.ts";
24
+
25
+ const PACKAGE_NAME = "@anthropic-ai/claude-code";
26
+ export const CLAUDE_PROBE_CONTRACT_VERSION = 2;
27
+ const DEFAULT_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_OUTPUT_CAP = 2 * 1024 * 1024;
29
+ const AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] as const;
30
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: this intentionally strips ANSI escape sequences.
31
+ const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
32
+ const VOLATILE_KEYS = new Set([
33
+ "id",
34
+ "message_id",
35
+ "session_id",
36
+ "request_id",
37
+ "uuid",
38
+ "timestamp",
39
+ "created_at",
40
+ "duration_ms",
41
+ "duration_api_ms",
42
+ "total_cost_usd",
43
+ "cost_usd",
44
+ "usage",
45
+ ]);
46
+ const SAFE_INIT_FIELDS = new Set([
47
+ "permissionMode",
48
+ "claude_code_version",
49
+ "output_style",
50
+ "fast_mode_state",
51
+ ]);
52
+ const SENSITIVE_KEY_PATTERN =
53
+ /(?:token|secret|credential|password|api.?key|email|account|organization|user.?name)/iu;
54
+
55
+ export interface ClaudeRuntimeCommandPlan {
56
+ root: string;
57
+ installRoot: string;
58
+ home: string;
59
+ config: string;
60
+ repo: string;
61
+ packageSpec: string;
62
+ binary: string;
63
+ install: CommandSpec;
64
+ initializeRepo: CommandSpec;
65
+ verifyPackage: CommandSpec;
66
+ version: CommandSpec;
67
+ help: CommandSpec;
68
+ doctor: CommandSpec;
69
+ autoModeDefaults: CommandSpec;
70
+ init: CommandSpec;
71
+ probes: Array<{ name: string; command: CommandSpec }>;
72
+ }
73
+
74
+ export interface CaptureClaudeRuntimeOptions {
75
+ version: string;
76
+ tempDir: string;
77
+ dryRun?: boolean;
78
+ keepRoot?: boolean;
79
+ env?: NodeJS.ProcessEnv;
80
+ runner?: CommandRunner;
81
+ timeoutMs?: number;
82
+ outputCapBytes?: number;
83
+ maxProbeAttempts?: number;
84
+ requireAuth?: boolean;
85
+ reuseProbes?: ClaudeProbeObservation[];
86
+ sandbox?: "docker" | "direct";
87
+ }
88
+
89
+ export interface ParsedClaudeStream {
90
+ events: unknown[];
91
+ eventTypes: string[];
92
+ init: {
93
+ tools: string[];
94
+ model: string | null;
95
+ capabilities: unknown | null;
96
+ stableFields: Record<string, unknown>;
97
+ } | null;
98
+ toolCalls: Array<{ id: string | null; name: string; input: unknown }>;
99
+ toolResults: Array<{
100
+ toolUseId: string | null;
101
+ content: string;
102
+ isError: boolean;
103
+ }>;
104
+ }
105
+
106
+ export function isClaudeProbeContractCurrent(
107
+ runtime: ClaudeRuntimeSnapshot | null | undefined,
108
+ ): boolean {
109
+ return runtime?.probe_contract_version === CLAUDE_PROBE_CONTRACT_VERSION;
110
+ }
111
+
112
+ interface ProbeDefinition {
113
+ name: string;
114
+ allowedTools: string[];
115
+ prompt: string;
116
+ }
117
+
118
+ const PROBES: ProbeDefinition[] = [
119
+ {
120
+ name: "read-lines-9-10-tab-prefix",
121
+ allowedTools: ["Read"],
122
+ prompt:
123
+ "Use the Read tool exactly once on ./read-fixture.txt with offset 9 and limit 2. Then stop. Do not modify files or use any other tool.",
124
+ },
125
+ {
126
+ name: "task-metadata-delete-contract",
127
+ allowedTools: ["TaskCreate", "TaskGet", "TaskUpdate", "TaskList"],
128
+ prompt:
129
+ 'Exercise only the task tools and continue through every step even if one call reports an error. Create one task named "probe-task" with metadata {"probe":"remove","keep":"yes"}; update metadata with {"probe":null,"count":3,"flags":["ready"],"details":{"source":"claude-watch"}}; get the resulting task record; permanently delete it with TaskUpdate status "deleted"; get the deleted ID again expecting a not-found error; then list tasks to confirm it is absent. Do not perform other work or use other tools.',
130
+ },
131
+ ];
132
+
133
+ function safeVersion(version: string): string {
134
+ if (
135
+ !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
136
+ version,
137
+ )
138
+ ) {
139
+ throw new Error(`Invalid exact Claude version: ${version}`);
140
+ }
141
+ return version;
142
+ }
143
+
144
+ function command(
145
+ label: string,
146
+ executable: string,
147
+ args: string[],
148
+ cwd: string,
149
+ env: NodeJS.ProcessEnv,
150
+ timeoutMs: number,
151
+ outputCapBytes: number,
152
+ stdin?: string,
153
+ ): CommandSpec {
154
+ return {
155
+ command: executable,
156
+ args,
157
+ cwd,
158
+ env,
159
+ timeoutMs,
160
+ outputCapBytes,
161
+ stdin,
162
+ label,
163
+ };
164
+ }
165
+
166
+ export function createClaudeRuntimeCommandPlan(
167
+ version: string,
168
+ root: string,
169
+ options: Pick<
170
+ CaptureClaudeRuntimeOptions,
171
+ "env" | "timeoutMs" | "outputCapBytes"
172
+ > = {},
173
+ ): ClaudeRuntimeCommandPlan {
174
+ safeVersion(version);
175
+ const installRoot = join(root, "install");
176
+ const home = join(root, "home");
177
+ const config = join(root, "config");
178
+ const repo = join(root, "repo");
179
+ const binary = join(
180
+ installRoot,
181
+ "node_modules",
182
+ ".bin",
183
+ process.platform === "win32" ? "claude.cmd" : "claude",
184
+ );
185
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
186
+ const outputCapBytes = options.outputCapBytes ?? DEFAULT_OUTPUT_CAP;
187
+ const suppliedEnv = options.env ?? process.env;
188
+ const commonEnv: NodeJS.ProcessEnv = {
189
+ HOME: home,
190
+ USERPROFILE: home,
191
+ XDG_CONFIG_HOME: config,
192
+ CLAUDE_CONFIG_DIR: config,
193
+ DISABLE_UPDATES: "1",
194
+ DISABLE_AUTOUPDATER: "1",
195
+ CI: "1",
196
+ NO_COLOR: "1",
197
+ COLUMNS: "120",
198
+ LINES: "40",
199
+ TERM: "dumb",
200
+ LANG: "C.UTF-8",
201
+ LC_ALL: "C.UTF-8",
202
+ TZ: "UTC",
203
+ PATH: suppliedEnv.PATH ?? "/usr/bin:/bin",
204
+ };
205
+ const runtimeEnv: NodeJS.ProcessEnv = { ...commonEnv };
206
+ for (const key of AUTH_ENV_KEYS) {
207
+ if (suppliedEnv[key]) runtimeEnv[key] = suppliedEnv[key];
208
+ }
209
+ const packageSpec = `${PACKAGE_NAME}@${version}`;
210
+ const streamArgs = [
211
+ "-p",
212
+ "Reply with exactly OK and do not use tools.",
213
+ "--safe-mode",
214
+ "--output-format",
215
+ "stream-json",
216
+ "--verbose",
217
+ "--permission-mode",
218
+ "dontAsk",
219
+ "--no-session-persistence",
220
+ "--max-budget-usd",
221
+ "0.05",
222
+ ];
223
+ const autoModeDefaults = command(
224
+ "auto-mode-defaults",
225
+ binary,
226
+ ["auto-mode", "defaults"],
227
+ repo,
228
+ runtimeEnv,
229
+ timeoutMs,
230
+ outputCapBytes,
231
+ );
232
+ const probeCommands = PROBES.map((probe) => ({
233
+ name: probe.name,
234
+ command: command(
235
+ `probe:${probe.name}`,
236
+ binary,
237
+ [
238
+ "-p",
239
+ probe.prompt,
240
+ "--safe-mode",
241
+ "--output-format",
242
+ "stream-json",
243
+ "--verbose",
244
+ "--permission-mode",
245
+ "dontAsk",
246
+ "--no-session-persistence",
247
+ "--tools",
248
+ probe.allowedTools.join(","),
249
+ "--allowedTools",
250
+ probe.allowedTools.join(","),
251
+ "--max-budget-usd",
252
+ "0.25",
253
+ ],
254
+ repo,
255
+ runtimeEnv,
256
+ Math.min(timeoutMs, 60_000),
257
+ outputCapBytes,
258
+ ),
259
+ }));
260
+ return {
261
+ root,
262
+ installRoot,
263
+ home,
264
+ config,
265
+ repo,
266
+ packageSpec,
267
+ binary,
268
+ install: command(
269
+ "install",
270
+ "npm",
271
+ [
272
+ "install",
273
+ "--prefix",
274
+ installRoot,
275
+ "--no-package-lock",
276
+ "--no-audit",
277
+ "--no-fund",
278
+ packageSpec,
279
+ ],
280
+ root,
281
+ commonEnv,
282
+ Math.max(timeoutMs, 300_000),
283
+ outputCapBytes,
284
+ ),
285
+ initializeRepo: command(
286
+ "git-init",
287
+ "/usr/bin/git",
288
+ ["init", "--quiet"],
289
+ repo,
290
+ commonEnv,
291
+ timeoutMs,
292
+ outputCapBytes,
293
+ ),
294
+ verifyPackage: command(
295
+ "verify-package",
296
+ "node",
297
+ [
298
+ "-e",
299
+ "const p=require(process.argv[1]); process.stdout.write(String(p.version))",
300
+ join(installRoot, "node_modules", PACKAGE_NAME, "package.json"),
301
+ ],
302
+ root,
303
+ commonEnv,
304
+ timeoutMs,
305
+ outputCapBytes,
306
+ ),
307
+ version: command(
308
+ "version",
309
+ binary,
310
+ ["--version"],
311
+ repo,
312
+ commonEnv,
313
+ timeoutMs,
314
+ outputCapBytes,
315
+ ),
316
+ help: command(
317
+ "help",
318
+ binary,
319
+ ["--help"],
320
+ repo,
321
+ commonEnv,
322
+ timeoutMs,
323
+ outputCapBytes,
324
+ ),
325
+ doctor: command(
326
+ "doctor",
327
+ binary,
328
+ ["doctor"],
329
+ repo,
330
+ commonEnv,
331
+ timeoutMs,
332
+ outputCapBytes,
333
+ ),
334
+ autoModeDefaults,
335
+ init: command(
336
+ "init-stream",
337
+ binary,
338
+ streamArgs,
339
+ repo,
340
+ runtimeEnv,
341
+ Math.min(timeoutMs, 60_000),
342
+ outputCapBytes,
343
+ ),
344
+ probes: probeCommands,
345
+ };
346
+ }
347
+
348
+ function record(value: unknown): Record<string, unknown> | null {
349
+ return value !== null && typeof value === "object" && !Array.isArray(value)
350
+ ? (value as Record<string, unknown>)
351
+ : null;
352
+ }
353
+
354
+ function textContent(value: unknown): string {
355
+ if (typeof value === "string") return value;
356
+ if (Array.isArray(value))
357
+ return value.map(textContent).filter(Boolean).join("\n");
358
+ const object = record(value);
359
+ if (!object) return "";
360
+ return textContent(object.text ?? object.content ?? object.output ?? "");
361
+ }
362
+
363
+ function contentBlocks(event: Record<string, unknown>): unknown[] {
364
+ const message = record(event.message);
365
+ const content = message?.content ?? event.content;
366
+ return Array.isArray(content)
367
+ ? content
368
+ : content === undefined
369
+ ? []
370
+ : [content];
371
+ }
372
+
373
+ export function parseClaudeStream(output: string): ParsedClaudeStream {
374
+ const events: unknown[] = [];
375
+ for (const [index, rawLine] of output.split(/\r?\n/).entries()) {
376
+ const line = rawLine.trim();
377
+ if (!line) continue;
378
+ try {
379
+ events.push(JSON.parse(line));
380
+ } catch {
381
+ throw new Error(`Malformed Claude stream-json at line ${index + 1}`);
382
+ }
383
+ }
384
+ if (events.length === 0)
385
+ throw new Error("Claude stream-json contained no events");
386
+
387
+ let init: ParsedClaudeStream["init"] = null;
388
+ const eventTypes = new Set<string>();
389
+ const toolCalls: ParsedClaudeStream["toolCalls"] = [];
390
+ const toolResults: ParsedClaudeStream["toolResults"] = [];
391
+ for (const eventValue of events) {
392
+ const event = record(eventValue);
393
+ if (!event) continue;
394
+ const type = typeof event.type === "string" ? event.type : "unknown";
395
+ const subtype = typeof event.subtype === "string" ? event.subtype : null;
396
+ eventTypes.add(subtype ? `${type}/${subtype}` : type);
397
+ if (!init && type === "system" && subtype === "init") {
398
+ const tools = Array.isArray(event.tools)
399
+ ? event.tools.filter((tool): tool is string => typeof tool === "string")
400
+ : [];
401
+ const stableFields: Record<string, unknown> = {};
402
+ const structuralFields = new Set([
403
+ "type",
404
+ "subtype",
405
+ "tools",
406
+ "model",
407
+ "capabilities",
408
+ "cwd",
409
+ "session_id",
410
+ ]);
411
+ for (const key of Object.keys(event).sort()) {
412
+ if (!structuralFields.has(key) && SAFE_INIT_FIELDS.has(key))
413
+ stableFields[key] = normalizeVolatile(event[key]);
414
+ }
415
+ init = {
416
+ tools: [...new Set(tools)].sort(),
417
+ model: typeof event.model === "string" ? event.model : null,
418
+ capabilities: normalizeVolatile(event.capabilities ?? null),
419
+ stableFields,
420
+ };
421
+ }
422
+ for (const blockValue of contentBlocks(event)) {
423
+ const block = record(blockValue);
424
+ if (!block) continue;
425
+ if (block.type === "tool_use" && typeof block.name === "string") {
426
+ toolCalls.push({
427
+ id: typeof block.id === "string" ? block.id : null,
428
+ name: block.name,
429
+ input: normalizeVolatile(block.input ?? null),
430
+ });
431
+ } else if (block.type === "tool_result") {
432
+ toolResults.push({
433
+ toolUseId:
434
+ typeof block.tool_use_id === "string" ? block.tool_use_id : null,
435
+ content: normalizeToolResultText(textContent(block.content)),
436
+ isError: block.is_error === true,
437
+ });
438
+ }
439
+ }
440
+ }
441
+ return {
442
+ events,
443
+ eventTypes: [...eventTypes].sort(),
444
+ init,
445
+ toolCalls,
446
+ toolResults,
447
+ };
448
+ }
449
+
450
+ function normalizeString(value: string): string {
451
+ return value
452
+ .replace(ANSI_PATTERN, "")
453
+ .replace(/\b(?:sk-ant|sk-|oauth-)[A-Za-z0-9_-]{8,}\b/g, "<redacted>")
454
+ .replace(/[A-Fa-f0-9]{8}-[A-Fa-f0-9-]{27,}/g, "<id>")
455
+ .replace(/\b20\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z\b/g, "<timestamp>")
456
+ .replace(
457
+ /(["']?(?:createdAt|updatedAt|created_at|updated_at)["']?\s*[:=]\s*)\d{10,}/gu,
458
+ "$1<timestamp>",
459
+ )
460
+ .replace(/\/tmp\/[\w./-]+/g, "<tmp>")
461
+ .replace(/\\tmp\\[-\w.\\]+/g, "<tmp>");
462
+ }
463
+
464
+ export function normalizeText(value: string): string {
465
+ return normalizeString(value).trim();
466
+ }
467
+
468
+ export function normalizeToolResultText(value: string): string {
469
+ return normalizeString(value);
470
+ }
471
+
472
+ export function normalizeVolatile(value: unknown): unknown {
473
+ if (Array.isArray(value)) return value.map(normalizeVolatile);
474
+ if (typeof value === "string") return normalizeText(value);
475
+ const object = record(value);
476
+ if (!object) return value;
477
+ const normalized: Record<string, unknown> = {};
478
+ for (const key of Object.keys(object).sort()) {
479
+ if (!VOLATILE_KEYS.has(key) && !SENSITIVE_KEY_PATTERN.test(key))
480
+ normalized[key] = normalizeVolatile(object[key]);
481
+ }
482
+ return normalized;
483
+ }
484
+
485
+ function stableJson(value: unknown): string {
486
+ return JSON.stringify(normalizeVolatile(value));
487
+ }
488
+
489
+ function hash(value: string): string {
490
+ return createHash("sha256").update(value).digest("hex");
491
+ }
492
+
493
+ function semanticProbe(probe: ClaudeProbeObservation): unknown {
494
+ return {
495
+ name: probe.name,
496
+ status: probe.status,
497
+ assertions: probe.assertions,
498
+ error: probe.error,
499
+ };
500
+ }
501
+
502
+ function namesDiff(
503
+ before: string[],
504
+ after: string[],
505
+ ): { added: string[]; removed: string[] } {
506
+ const oldSet = new Set(before);
507
+ const newSet = new Set(after);
508
+ return {
509
+ added: [...newSet].filter((name) => !oldSet.has(name)).sort(),
510
+ removed: [...oldSet].filter((name) => !newSet.has(name)).sort(),
511
+ };
512
+ }
513
+
514
+ function linesDiff(
515
+ before: string,
516
+ after: string,
517
+ ): { added: string[]; removed: string[] } {
518
+ const oldLines = new Set(before.split("\n").filter(Boolean));
519
+ const newLines = new Set(after.split("\n").filter(Boolean));
520
+ return {
521
+ added: [...newLines].filter((line) => !oldLines.has(line)).sort(),
522
+ removed: [...oldLines].filter((line) => !newLines.has(line)).sort(),
523
+ };
524
+ }
525
+
526
+ export function diffClaudeRuntime(
527
+ previous: ClaudeRuntimeSnapshot,
528
+ current: ClaudeRuntimeSnapshot,
529
+ ): ClaudeRuntimeDiff {
530
+ const tools = namesDiff(
531
+ previous.init?.tools ?? [],
532
+ current.init?.tools ?? [],
533
+ );
534
+ const eventTypes = namesDiff(
535
+ previous.event_inventory,
536
+ current.event_inventory,
537
+ );
538
+ const helpLines = linesDiff(previous.help_text, current.help_text);
539
+ const oldProbes = new Map(
540
+ previous.probes.map((probe) => [
541
+ probe.name,
542
+ stableJson(semanticProbe(probe)),
543
+ ]),
544
+ );
545
+ const newProbes = new Map(
546
+ current.probes.map((probe) => [
547
+ probe.name,
548
+ stableJson(semanticProbe(probe)),
549
+ ]),
550
+ );
551
+ const changedProbes = [...new Set([...oldProbes.keys(), ...newProbes.keys()])]
552
+ .filter((name) => oldProbes.get(name) !== newProbes.get(name))
553
+ .sort();
554
+ return {
555
+ tools_added: tools.added,
556
+ tools_removed: tools.removed,
557
+ help_changed: previous.help_hash !== current.help_hash,
558
+ help_lines_added: helpLines.added,
559
+ help_lines_removed: helpLines.removed,
560
+ doctor_changed: stableJson(previous.doctor) !== stableJson(current.doctor),
561
+ init_changed: stableJson(previous.init) !== stableJson(current.init),
562
+ auto_mode_defaults_changed:
563
+ stableJson(previous.auto_mode_defaults) !==
564
+ stableJson(current.auto_mode_defaults),
565
+ event_types_added: eventTypes.added,
566
+ event_types_removed: eventTypes.removed,
567
+ changed_probes: changedProbes,
568
+ };
569
+ }
570
+
571
+ export const diffRuntimeSnapshots = diffClaudeRuntime;
572
+
573
+ function checkResult(result: CommandResult, label: string): void {
574
+ if (result.timedOut) throw new Error(`Command ${label} timed out`);
575
+ if (result.truncated)
576
+ throw new Error(`Command ${label} exceeded its output cap`);
577
+ if (result.exitCode !== 0) throw new Error(`Command ${label} exited nonzero`);
578
+ }
579
+
580
+ function authAvailable(env: NodeJS.ProcessEnv): boolean {
581
+ return AUTH_ENV_KEYS.some(
582
+ (key) => typeof env[key] === "string" && (env[key]?.length ?? 0) > 0,
583
+ );
584
+ }
585
+
586
+ function isAuthFailure(result: CommandResult): boolean {
587
+ const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
588
+ return /not logged in|authentication|authenticate|api key|oauth|unauthorized/.test(
589
+ text,
590
+ );
591
+ }
592
+
593
+ function doctorSummary(result: CommandResult, env: NodeJS.ProcessEnv): string {
594
+ let combined = `${result.stdout}\n${result.stderr}`;
595
+ for (const key of AUTH_ENV_KEYS) {
596
+ const secret = env[key];
597
+ if (secret) combined = combined.split(secret).join("<redacted>");
598
+ }
599
+ return normalizeText(combined)
600
+ .split(/\r?\n/)
601
+ .filter(Boolean)
602
+ .slice(0, 40)
603
+ .join("\n")
604
+ .slice(0, 8_000);
605
+ }
606
+
607
+ function normalizeDoctorVersion(summary: string, version: string): string {
608
+ return summary
609
+ .replaceAll(version, "<version>")
610
+ .replace(/^Commit: [0-9a-f]+$/gmu, "Commit: <commit>");
611
+ }
612
+
613
+ export function extractAutoModeDefaults(output: string): unknown | null {
614
+ const normalized = normalizeText(output);
615
+ if (!normalized) return null;
616
+ try {
617
+ return normalizeVolatile(JSON.parse(normalized));
618
+ } catch {
619
+ // Older releases may print the defaults as text rather than JSON.
620
+ const lines = normalized
621
+ .split(/\r?\n/)
622
+ .map((line) => line.trim())
623
+ .filter(Boolean);
624
+ return lines.length > 0 ? [...new Set(lines)].sort() : null;
625
+ }
626
+ }
627
+
628
+ async function filesystemInventory(root: string): Promise<Map<string, string>> {
629
+ const inventory = new Map<string, string>();
630
+ async function walk(directory: string): Promise<void> {
631
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
632
+ if (entry.name === ".git") continue;
633
+ const path = join(directory, entry.name);
634
+ const name = relative(root, path);
635
+ if (entry.isDirectory()) await walk(path);
636
+ else if (entry.isFile()) {
637
+ const info = await stat(path);
638
+ inventory.set(
639
+ name,
640
+ `${info.size}:${hash(await readFile(path, "utf8"))}`,
641
+ );
642
+ }
643
+ }
644
+ }
645
+ await walk(root);
646
+ return inventory;
647
+ }
648
+
649
+ function filesystemChanges(
650
+ before: Map<string, string>,
651
+ after: Map<string, string>,
652
+ ): string[] {
653
+ const names = [...new Set([...before.keys(), ...after.keys()])].sort();
654
+ return names.flatMap((name) => {
655
+ if (!before.has(name)) return [`added:${name}`];
656
+ if (!after.has(name)) return [`removed:${name}`];
657
+ return before.get(name) === after.get(name) ? [] : [`changed:${name}`];
658
+ });
659
+ }
660
+
661
+ function skippedProbe(name: string, reason: string): ClaudeProbeObservation {
662
+ return {
663
+ name,
664
+ status: "skipped",
665
+ attempts: 0,
666
+ assertions: {},
667
+ tool_calls: [],
668
+ tool_results: [],
669
+ filesystem_changes: [],
670
+ error: reason,
671
+ };
672
+ }
673
+
674
+ async function runProbe(
675
+ definition: ProbeDefinition,
676
+ spec: CommandSpec,
677
+ runner: CommandRunner,
678
+ repo: string,
679
+ maxAttempts: number,
680
+ ): Promise<ClaudeProbeObservation> {
681
+ let lastError = "Probe produced no conclusive tool transcript";
682
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
683
+ const before = await filesystemInventory(repo);
684
+ const result = await runner(spec);
685
+ const after = await filesystemInventory(repo);
686
+ if (result.timedOut || result.truncated || result.exitCode !== 0) {
687
+ lastError = result.timedOut
688
+ ? "Probe timed out"
689
+ : result.truncated
690
+ ? "Probe exceeded output cap"
691
+ : "Probe exited nonzero";
692
+ continue;
693
+ }
694
+ let parsed: ParsedClaudeStream;
695
+ try {
696
+ parsed = parseClaudeStream(result.stdout);
697
+ } catch (error) {
698
+ lastError =
699
+ error instanceof Error ? error.message : "Malformed probe stream";
700
+ continue;
701
+ }
702
+ const disallowed = parsed.toolCalls.find(
703
+ (call) => !definition.allowedTools.includes(call.name),
704
+ );
705
+ const evaluation = evaluateProbe(definition.name, parsed);
706
+ const observation: ClaudeProbeObservation = {
707
+ name: definition.name,
708
+ status: disallowed
709
+ ? "failed"
710
+ : evaluation.complete
711
+ ? "passed"
712
+ : "inconclusive",
713
+ attempts: attempt,
714
+ assertions: evaluation.assertions,
715
+ tool_calls: parsed.toolCalls.map(({ name, input }) => ({ name, input })),
716
+ tool_results: parsed.toolResults.map(
717
+ (resultValue) => resultValue.content,
718
+ ),
719
+ filesystem_changes: filesystemChanges(before, after),
720
+ error: disallowed
721
+ ? `Tool outside allowlist: ${disallowed.name}`
722
+ : evaluation.complete
723
+ ? null
724
+ : "Probe transcript did not complete the fixed contract",
725
+ };
726
+ if (observation.status !== "inconclusive" || attempt === maxAttempts)
727
+ return observation;
728
+ lastError = "Probe transcript did not complete the fixed contract";
729
+ }
730
+ return {
731
+ name: definition.name,
732
+ status: "inconclusive",
733
+ attempts: maxAttempts,
734
+ assertions: {},
735
+ tool_calls: [],
736
+ tool_results: [],
737
+ filesystem_changes: [],
738
+ error: lastError,
739
+ };
740
+ }
741
+
742
+ /**
743
+ * Installs and captures one exact Claude release in its own disposable root.
744
+ * Dry-run returns null before creating directories or invoking install/auth commands.
745
+ */
746
+ export async function captureClaudeRuntime(
747
+ options: CaptureClaudeRuntimeOptions,
748
+ ): Promise<ClaudeRuntimeSnapshot | null> {
749
+ safeVersion(options.version);
750
+ if (options.dryRun) return null;
751
+ const root = await mkdtemp(
752
+ join(resolve(options.tempDir), `claude-${options.version}-`),
753
+ );
754
+ const plan = createClaudeRuntimeCommandPlan(options.version, root, options);
755
+ const runner = options.runner ?? runBoundedCommand;
756
+ const sandboxed = (spec: CommandSpec): CommandSpec =>
757
+ options.sandbox === "direct" ? spec : sandboxClaudeCommand(spec, root);
758
+ try {
759
+ await Promise.all([
760
+ mkdir(plan.installRoot, { recursive: true }),
761
+ mkdir(plan.home, { recursive: true }),
762
+ mkdir(plan.config, { recursive: true }),
763
+ mkdir(plan.repo, { recursive: true }),
764
+ ]);
765
+ await writeFile(
766
+ join(plan.repo, "read-fixture.txt"),
767
+ [
768
+ "one",
769
+ "two",
770
+ "three",
771
+ "four",
772
+ "five",
773
+ "six",
774
+ "seven",
775
+ "eight",
776
+ "line9",
777
+ "line10",
778
+ "eleven",
779
+ ].join("\n"),
780
+ "utf8",
781
+ );
782
+
783
+ checkResult(await runner(sandboxed(plan.install)), plan.install.label);
784
+ checkResult(await runner(plan.initializeRepo), plan.initializeRepo.label);
785
+ const packageResult = await runner(sandboxed(plan.verifyPackage));
786
+ checkResult(packageResult, plan.verifyPackage.label);
787
+ if (packageResult.stdout.trim() !== options.version) {
788
+ throw new Error(
789
+ `Installed Claude package version mismatch: expected ${options.version}`,
790
+ );
791
+ }
792
+ const versionResult = await runner(sandboxed(plan.version));
793
+ checkResult(versionResult, plan.version.label);
794
+ const reportedVersion =
795
+ /^\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)/u.exec(
796
+ versionResult.stdout,
797
+ )?.[1];
798
+ if (reportedVersion !== options.version) {
799
+ throw new Error(`Claude --version mismatch: expected ${options.version}`);
800
+ }
801
+ const helpResult = await runner(sandboxed(plan.help));
802
+ checkResult(helpResult, plan.help.label);
803
+ const normalizedHelp = normalizeText(helpResult.stdout);
804
+ const safeModeSupported = normalizedHelp.includes("--safe-mode");
805
+ if (!safeModeSupported) {
806
+ plan.init.args = plan.init.args.filter(
807
+ (argument) => argument !== "--safe-mode",
808
+ );
809
+ for (const probe of plan.probes) {
810
+ probe.command.args = probe.command.args.filter(
811
+ (argument) => argument !== "--safe-mode",
812
+ );
813
+ }
814
+ }
815
+ const doctorResult = await runner(sandboxed(plan.doctor));
816
+ if (doctorResult.truncated) checkResult(doctorResult, plan.doctor.label);
817
+ const autoResult = await runner(sandboxed(plan.autoModeDefaults));
818
+ if (autoResult.truncated)
819
+ checkResult(autoResult, plan.autoModeDefaults.label);
820
+
821
+ let init: ClaudeRuntimeSnapshot["init"] = null;
822
+ let eventInventory: string[] = [];
823
+ const probes: ClaudeProbeObservation[] = [];
824
+ if (authAvailable(plan.init.env)) {
825
+ const initResult = await runner(sandboxed(plan.init));
826
+ if (initResult.exitCode !== 0 && isAuthFailure(initResult)) {
827
+ throw new Error(
828
+ "Claude runtime authentication failed. Check the ANTHROPIC_API_KEY Actions secret and rerun the watcher.",
829
+ );
830
+ } else {
831
+ checkResult(initResult, plan.init.label);
832
+ const parsed = parseClaudeStream(initResult.stdout);
833
+ if (!parsed.init) {
834
+ throw new Error(
835
+ "Claude stream completed without a system/init event; runtime inventory is incomplete.",
836
+ );
837
+ }
838
+ init = {
839
+ tools: parsed.init.tools,
840
+ model: parsed.init.model,
841
+ capabilities: parsed.init.capabilities,
842
+ stable_fields: parsed.init.stableFields,
843
+ };
844
+ eventInventory = parsed.eventTypes;
845
+ if (options.reuseProbes) {
846
+ probes.push(
847
+ ...options.reuseProbes.map(
848
+ (probe) => normalizeVolatile(probe) as ClaudeProbeObservation,
849
+ ),
850
+ );
851
+ } else {
852
+ for (const [index, probe] of PROBES.entries()) {
853
+ const probePlan = plan.probes[index];
854
+ if (!probePlan)
855
+ throw new Error(`Missing command plan for probe ${probe.name}`);
856
+ probes.push(
857
+ await runProbe(
858
+ probe,
859
+ sandboxed(probePlan.command),
860
+ runner,
861
+ plan.repo,
862
+ Math.max(1, Math.min(options.maxProbeAttempts ?? 2, 3)),
863
+ ),
864
+ );
865
+ }
866
+ }
867
+ }
868
+ } else if (options.requireAuth !== false) {
869
+ throw new Error(
870
+ "ANTHROPIC_API_KEY is required for Claude runtime inventory and probes.",
871
+ );
872
+ } else {
873
+ for (const probe of PROBES)
874
+ probes.push(skippedProbe(probe.name, "Authentication unavailable"));
875
+ }
876
+
877
+ const snapshotWithoutDigest = {
878
+ probe_contract_version: CLAUDE_PROBE_CONTRACT_VERSION,
879
+ version: options.version,
880
+ version_output: normalizeText(versionResult.stdout),
881
+ help_text: normalizedHelp,
882
+ help_hash: hash(normalizedHelp),
883
+ doctor: {
884
+ exit_code: doctorResult.exitCode ?? -1,
885
+ summary: doctorResult.timedOut
886
+ ? "timed_out"
887
+ : normalizeDoctorVersion(
888
+ doctorSummary(doctorResult, plan.doctor.env),
889
+ options.version,
890
+ ),
891
+ },
892
+ auto_mode_defaults:
893
+ !autoResult.timedOut && autoResult.exitCode === 0
894
+ ? extractAutoModeDefaults(autoResult.stdout)
895
+ : null,
896
+ init,
897
+ event_inventory: eventInventory,
898
+ probes,
899
+ };
900
+ const semanticSnapshot = {
901
+ ...snapshotWithoutDigest,
902
+ probes: probes.map(semanticProbe),
903
+ };
904
+ return {
905
+ ...snapshotWithoutDigest,
906
+ digest: hash(stableJson(semanticSnapshot)),
907
+ };
908
+ } finally {
909
+ if (!options.keepRoot) await rm(root, { recursive: true, force: true });
910
+ }
911
+ }