cyrus-claude-runner 0.2.49 → 0.2.50

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.
@@ -1,11 +1,11 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { type IAgentRunner } from "cyrus-core";
4
+ import { type IMessageFormatter } from "./formatter.js";
5
+ import type { ClaudeRunnerConfig, ClaudeRunnerEvents, ClaudeSessionInfo } from "./types.js";
4
6
  export declare class AbortError extends Error {
5
7
  constructor(message?: string);
6
8
  }
7
- import { type IMessageFormatter } from "./formatter.js";
8
- import type { ClaudeRunnerConfig, ClaudeRunnerEvents, ClaudeSessionInfo } from "./types.js";
9
9
  export declare interface ClaudeRunner {
10
10
  on<K extends keyof ClaudeRunnerEvents>(event: K, listener: ClaudeRunnerEvents[K]): this;
11
11
  emit<K extends keyof ClaudeRunnerEvents>(event: K, ...args: Parameters<ClaudeRunnerEvents[K]>): boolean;
@@ -26,12 +26,14 @@ export declare class ClaudeRunner extends EventEmitter implements IAgentRunner {
26
26
  private readableLogStream;
27
27
  private messages;
28
28
  private streamingPrompt;
29
+ private activeQuery;
29
30
  private cyrusHome;
30
31
  private formatter;
31
32
  private pendingResultMessage;
32
33
  private canUseToolCallback;
33
34
  private repositoryEnv;
34
- constructor(config: ClaudeRunnerConfig);
35
+ private keepSessionWarm;
36
+ constructor(config: ClaudeRunnerConfig, keepSessionWarm?: boolean);
35
37
  /**
36
38
  * Create the canUseTool callback for intercepting AskUserQuestion tool calls.
37
39
  *
@@ -71,6 +73,21 @@ export declare class ClaudeRunner extends EventEmitter implements IAgentRunner {
71
73
  userPromptVersion?: string;
72
74
  systemPromptVersion?: string;
73
75
  }): void;
76
+ /**
77
+ * Interrupt the current turn without killing the session.
78
+ * The session stays warm and can accept new messages.
79
+ *
80
+ * Only safe to call on warm sessions (see {@link isWarm}). Calling
81
+ * `interrupt()` on a non-warm session aborts the underlying request and
82
+ * causes the SDK to emit a "Request was aborted" error. Callers should
83
+ * gate on `isWarm()` and prefer `stop()` for non-warm sessions.
84
+ */
85
+ interrupt(): Promise<void>;
86
+ /**
87
+ * Whether this runner keeps its SDK session warm between turns. Warm
88
+ * sessions can be safely interrupted; non-warm sessions cannot.
89
+ */
90
+ isWarm(): boolean;
74
91
  /**
75
92
  * Stop the current Claude session
76
93
  */
@@ -1 +1 @@
1
- {"version":3,"file":"ClaudeRunner.d.ts","sourceRoot":"","sources":["../src/ClaudeRunner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAW3C,OAAO,EAIN,KAAK,UAAU,EAEf,MAAM,gCAAgC,CAAC;AAExC,OAAO,EAEN,KAAK,YAAY,EAGjB,MAAM,YAAY,CAAC;AAIpB,qBAAa,UAAW,SAAQ,KAAK;gBACxB,OAAO,CAAC,EAAE,MAAM;CAI5B;AAwCD,OAAO,EAA0B,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAKhF,OAAO,KAAK,EACX,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,YAAY,CAAC;AAEpB,MAAM,CAAC,OAAO,WAAW,YAAY;IACpC,EAAE,CAAC,CAAC,SAAS,MAAM,kBAAkB,EACpC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAC7B,IAAI,CAAC;IACR,IAAI,CAAC,CAAC,SAAS,MAAM,kBAAkB,EACtC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC;CACX;AAED;;GAEG;AACH,qBAAa,YAAa,SAAQ,YAAa,YAAW,YAAY;IACrE;;OAEG;IACH,QAAQ,CAAC,sBAAsB,QAAQ;IAEvC,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,MAAM,CAAU;IACxB,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAkC;IACrD,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,iBAAiB,CAA4B;IACrD,OAAO,CAAC,QAAQ,CAAoB;IACpC,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,oBAAoB,CAA2B;IACvD,OAAO,CAAC,kBAAkB,CAAyB;IACnD,OAAO,CAAC,aAAa,CAA8B;gBAEvC,MAAM,EAAE,kBAAkB;IAkBtC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,wBAAwB;IA4GhC;;OAEG;IACG,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAIvD;;OAEG;IACG,cAAc,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAIxE;;OAEG;IACH,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAOvC;;OAEG;IACH,cAAc,IAAI,IAAI;IAMtB;;OAEG;YACW,eAAe;IAyY7B;;OAEG;IACH,oBAAoB,CAAC,QAAQ,EAAE;QAC9B,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAC;KAC7B,GAAG,IAAI;IAuCR;;OAEG;IACH,IAAI,IAAI,IAAI;IAkBZ;;OAEG;IACH,SAAS,IAAI,OAAO;IAIpB;;OAEG;IACH,WAAW,IAAI,OAAO;IAQtB;;OAEG;IACH,cAAc,IAAI,iBAAiB,GAAG,IAAI;IAI1C;;OAEG;IACH,WAAW,IAAI,UAAU,EAAE;IAI3B;;OAEG;IACH,YAAY,IAAI,iBAAiB;IAIjC;;OAEG;IACH,OAAO,CAAC,cAAc;IA6CtB;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAyBzB;;OAEG;IACH,OAAO,CAAC,YAAY;IA0EpB;;OAEG;IACH,OAAO,CAAC,qBAAqB;CAsG7B"}
1
+ {"version":3,"file":"ClaudeRunner.d.ts","sourceRoot":"","sources":["../src/ClaudeRunner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAU3C,OAAO,EAKN,KAAK,UAAU,EAEf,MAAM,gCAAgC,CAAC;AAExC,OAAO,EAEN,KAAK,YAAY,EAIjB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAA0B,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAUhF,OAAO,KAAK,EACX,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,YAAY,CAAC;AAGpB,qBAAa,UAAW,SAAQ,KAAK;gBACxB,OAAO,CAAC,EAAE,MAAM;CAI5B;AA4KD,MAAM,CAAC,OAAO,WAAW,YAAY;IACpC,EAAE,CAAC,CAAC,SAAS,MAAM,kBAAkB,EACpC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAC7B,IAAI,CAAC;IACR,IAAI,CAAC,CAAC,SAAS,MAAM,kBAAkB,EACtC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC;CACX;AAED;;GAEG;AACH,qBAAa,YAAa,SAAQ,YAAa,YAAW,YAAY;IACrE;;OAEG;IACH,QAAQ,CAAC,sBAAsB,QAAQ;IAEvC,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,MAAM,CAAU;IACxB,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAkC;IACrD,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,iBAAiB,CAA4B;IACrD,OAAO,CAAC,QAAQ,CAAoB;IACpC,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAsB;IACzC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,oBAAoB,CAA2B;IACvD,OAAO,CAAC,kBAAkB,CAAyB;IACnD,OAAO,CAAC,aAAa,CAA8B;IACnD,OAAO,CAAC,eAAe,CAAU;gBAErB,MAAM,EAAE,kBAAkB,EAAE,eAAe,UAAQ;IAmB/D;;;;;;;;;;OAUG;IACH,OAAO,CAAC,wBAAwB;IA4GhC;;OAEG;IACG,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAIvD;;OAEG;IACG,cAAc,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAIxE;;OAEG;IACH,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAOvC;;OAEG;IACH,cAAc,IAAI,IAAI;IAMtB;;OAEG;YACW,eAAe;IAib7B;;OAEG;IACH,oBAAoB,CAAC,QAAQ,EAAE;QAC9B,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAC;KAC7B,GAAG,IAAI;IAuCR;;;;;;;;OAQG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAgBhC;;;OAGG;IACH,MAAM,IAAI,OAAO;IAIjB;;OAEG;IACH,IAAI,IAAI,IAAI;IAsBZ;;OAEG;IACH,SAAS,IAAI,OAAO;IAIpB;;OAEG;IACH,WAAW,IAAI,OAAO;IAQtB;;OAEG;IACH,cAAc,IAAI,iBAAiB,GAAG,IAAI;IAI1C;;OAEG;IACH,WAAW,IAAI,UAAU,EAAE;IAI3B;;OAEG;IACH,YAAY,IAAI,iBAAiB;IAIjC;;OAEG;IACH,OAAO,CAAC,cAAc;IA6CtB;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAyBzB;;OAEG;IACH,OAAO,CAAC,YAAY;IA0EpB;;OAEG;IACH,OAAO,CAAC,qBAAqB;CAsG7B"}
@@ -1,10 +1,13 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
3
- import { createRequire } from "node:module";
4
- import { dirname, join } from "node:path";
3
+ import { join } from "node:path";
5
4
  import { query, } from "@anthropic-ai/claude-agent-sdk";
6
- import { createLogger, StreamingPrompt, } from "cyrus-core";
5
+ import { createLogger, LogLevel, StreamingPrompt, } from "cyrus-core";
7
6
  import dotenv from "dotenv";
7
+ import { ClaudeMessageFormatter } from "./formatter.js";
8
+ import { buildHomeDirectoryDisallowedTools } from "./home-directory-restrictions.js";
9
+ import { checkLinuxSandboxRequirements, logSandboxRequirementFailures, } from "./sandbox-requirements.js";
10
+ import { buildBaseSessionEnv, normalizeMcpHttpTransport, } from "./session-env.js";
8
11
  // AbortError is no longer exported in v1.0.95, so we define it locally
9
12
  export class AbortError extends Error {
10
13
  constructor(message) {
@@ -12,38 +15,138 @@ export class AbortError extends Error {
12
15
  this.name = "AbortError";
13
16
  }
14
17
  }
15
- // Create a require function for resolving module paths in ESM
16
- const require = createRequire(import.meta.url);
17
18
  /**
18
- * Resolves the path to the Claude Agent SDK's cli.js executable.
19
- * This is needed because the SDK's default path resolution (via import.meta.url)
20
- * can fail in symlinked environments like global npm installs or pnpm workspaces.
21
- *
22
- * @returns The resolved path to cli.js, or undefined if resolution fails
19
+ * JSON.stringify replacer for Claude query options. The SDK's query options
20
+ * include non-serializable members (AbortController, async iterables,
21
+ * callbacks, pre-warmed sessions) replace them with diagnostic placeholders
22
+ * so debug logs remain valid JSON.
23
23
  */
24
- function resolveClaudeCodeExecutablePath() {
25
- try {
26
- // Resolve the SDK's main entry point using Node's module resolution
27
- const sdkPath = require.resolve("@anthropic-ai/claude-agent-sdk");
28
- // The SDK exports sdk.mjs, but cli.js is in the same directory
29
- const sdkDir = dirname(sdkPath);
30
- const cliPath = join(sdkDir, "cli.js");
31
- // Verify the cli.js file exists
32
- if (existsSync(cliPath)) {
33
- return cliPath;
34
- }
35
- console.warn(`[ClaudeRunner] Resolved SDK path but cli.js not found at: ${cliPath}`);
36
- return undefined;
24
+ function serializeQueryOptionsReplacer(_key, value) {
25
+ if (typeof value === "function") {
26
+ return `[Function${value.name ? `: ${value.name}` : ""}]`;
27
+ }
28
+ if (value instanceof AbortController) {
29
+ return "[AbortController]";
37
30
  }
38
- catch (error) {
39
- // This can happen if the SDK is not installed or path resolution fails
40
- // In this case, let the SDK use its own default resolution logic
41
- console.warn("[ClaudeRunner] Failed to resolve SDK executable path:", error instanceof Error ? error.message : error);
42
- return undefined;
31
+ if (value !== null &&
32
+ typeof value === "object" &&
33
+ Symbol.asyncIterator in value) {
34
+ return "[AsyncIterable]";
43
35
  }
36
+ return value;
37
+ }
38
+ function buildSanitizedQueryOptions(queryOptions) {
39
+ const o = (queryOptions.options ?? {});
40
+ const out = {};
41
+ if (typeof o.model === "string")
42
+ out.model = o.model;
43
+ if (typeof o.fallbackModel === "string")
44
+ out.fallbackModel = o.fallbackModel;
45
+ if (typeof o.maxTurns === "number")
46
+ out.maxTurns = o.maxTurns;
47
+ if (typeof o.outputFormat === "string")
48
+ out.outputFormat = o.outputFormat;
49
+ if (typeof o.cwd === "string")
50
+ out.cwd = o.cwd;
51
+ if (Array.isArray(o.allowedDirectories)) {
52
+ out.allowedDirectoryCount = o.allowedDirectories.length;
53
+ }
54
+ if (Array.isArray(o.settingSources)) {
55
+ out.settingSources = o.settingSources;
56
+ }
57
+ if (typeof o.resume === "string") {
58
+ out.resumeSessionId = o.resume;
59
+ }
60
+ if (typeof o.permissionMode === "string") {
61
+ out.permissionMode = o.permissionMode;
62
+ }
63
+ // System prompt — keep the shape, not the prose. Append text routinely
64
+ // contains long form documentation that may include token/auth keywords.
65
+ if (o.systemPrompt && typeof o.systemPrompt === "object") {
66
+ const sp = o.systemPrompt;
67
+ out.systemPrompt = {
68
+ type: sp.type,
69
+ preset: sp.preset,
70
+ hasAppend: typeof sp.append === "string" && sp.append.length > 0,
71
+ appendLength: typeof sp.append === "string" ? sp.append.length : 0,
72
+ };
73
+ }
74
+ // Tool allow/deny lists — bound the size so a 5000-entry list doesn't
75
+ // itself blow the attribute cap. Tool names like `Read(/abs/path/**)`
76
+ // are diagnostic gold and don't carry secrets.
77
+ const TOOL_LIST_PREVIEW = 50;
78
+ if (Array.isArray(o.allowedTools)) {
79
+ const arr = o.allowedTools;
80
+ out.allowedToolsCount = arr.length;
81
+ out.allowedToolsPreview = arr.slice(0, TOOL_LIST_PREVIEW);
82
+ }
83
+ if (Array.isArray(o.disallowedTools)) {
84
+ const arr = o.disallowedTools;
85
+ out.disallowedToolsCount = arr.length;
86
+ out.disallowedToolsPreview = arr.slice(0, TOOL_LIST_PREVIEW);
87
+ }
88
+ // MCP servers — names only. Inner config carries auth headers, URLs with
89
+ // tokens in query strings, etc.
90
+ if (o.mcpServers && typeof o.mcpServers === "object") {
91
+ out.mcpServerNames = Object.keys(o.mcpServers);
92
+ }
93
+ // Env — key names only, no values. Spreads `process.env`, so values are
94
+ // inherently sensitive.
95
+ if (o.env && typeof o.env === "object") {
96
+ const envKeys = Object.keys(o.env);
97
+ out.envKeyCount = envKeys.length;
98
+ // First 100 names is plenty to confirm what flowed through.
99
+ out.envKeyNamesPreview = envKeys.slice(0, 100);
100
+ }
101
+ // Presence flags rather than payload for opaque/large fields.
102
+ out.hasHooks = !!o.hooks;
103
+ out.hasPlugins =
104
+ Array.isArray(o.plugins) && o.plugins.length > 0;
105
+ out.hasCanUseTool = typeof o.canUseTool === "function";
106
+ out.hasSandbox = !!o.sandbox;
107
+ out.hasExtraArgs = !!o.extraArgs;
108
+ out.hasPathToClaudeCodeExecutable =
109
+ typeof o.pathToClaudeCodeExecutable === "string";
110
+ return out;
111
+ }
112
+ /**
113
+ * Flatten the sanitized query options into a set of primitive Sentry Logs
114
+ * attributes. Sentry attribute values must be primitives, so arrays and
115
+ * nested objects are joined into newline-separated strings (preview values
116
+ * are already bounded). Each top-level datum gets its own attribute key so a
117
+ * stray match in any one field can't filter the whole payload — and short
118
+ * scalar values rarely trip Sentry's pattern matchers in the first place.
119
+ */
120
+ function flattenSanitizedQueryOptions(sanitized) {
121
+ const ATTR_PREFIX = "cqo.";
122
+ const out = {};
123
+ for (const [key, value] of Object.entries(sanitized)) {
124
+ const attrKey = `${ATTR_PREFIX}${key}`;
125
+ if (value === null || value === undefined)
126
+ continue;
127
+ if (typeof value === "string" ||
128
+ typeof value === "number" ||
129
+ typeof value === "boolean") {
130
+ out[attrKey] = value;
131
+ continue;
132
+ }
133
+ if (Array.isArray(value)) {
134
+ out[attrKey] = value.map(String).join("\n");
135
+ continue;
136
+ }
137
+ if (typeof value === "object") {
138
+ // Nested object (e.g. systemPrompt summary). Stringify but keep it
139
+ // short — these summaries are intentionally tiny.
140
+ try {
141
+ out[attrKey] = JSON.stringify(value);
142
+ }
143
+ catch {
144
+ out[attrKey] = "[unserialisable]";
145
+ }
146
+ }
147
+ }
148
+ return out;
44
149
  }
45
- import { ClaudeMessageFormatter } from "./formatter.js";
46
- import { checkLinuxSandboxRequirements, logSandboxRequirementFailures, } from "./sandbox-requirements.js";
47
150
  /**
48
151
  * Manages Claude SDK sessions and communication
49
152
  */
@@ -60,14 +163,17 @@ export class ClaudeRunner extends EventEmitter {
60
163
  readableLogStream = null;
61
164
  messages = [];
62
165
  streamingPrompt = null;
166
+ activeQuery = null;
63
167
  cyrusHome;
64
168
  formatter;
65
169
  pendingResultMessage = null;
66
170
  canUseToolCallback;
67
171
  repositoryEnv = {};
68
- constructor(config) {
172
+ keepSessionWarm;
173
+ constructor(config, keepSessionWarm = false) {
69
174
  super();
70
175
  this.config = config;
176
+ this.keepSessionWarm = keepSessionWarm;
71
177
  this.logger = config.logger ?? createLogger({ component: "ClaudeRunner" });
72
178
  this.cyrusHome = config.cyrusHome;
73
179
  this.formatter = new ClaudeMessageFormatter();
@@ -213,7 +319,13 @@ export class ClaudeRunner extends EventEmitter {
213
319
  startedAt: new Date(),
214
320
  isRunning: true,
215
321
  };
216
- this.logger.info("Starting new session (session ID will be assigned by Claude)");
322
+ const isResumed = !!this.config.resumeSessionId;
323
+ this.logger.event(isResumed ? "session_resumed" : "session_started", {
324
+ resumeSessionId: this.config.resumeSessionId,
325
+ workingDirectory: this.config.workingDirectory,
326
+ model: this.config.model,
327
+ fallbackModel: this.config.fallbackModel,
328
+ });
217
329
  this.logger.debug("Working directory:", this.config.workingDirectory);
218
330
  // Ensure working directory exists
219
331
  if (this.config.workingDirectory) {
@@ -265,13 +377,24 @@ export class ClaudeRunner extends EventEmitter {
265
377
  ? [...processedAllowedTools, ...directoryTools]
266
378
  : directoryTools;
267
379
  }
268
- // Process disallowed tools - no defaults, just pass through
269
- // Only pass if array is non-empty
270
- const processedDisallowedTools = this.config.disallowedTools && this.config.disallowedTools.length > 0
271
- ? this.config.disallowedTools
272
- : undefined;
380
+ // Build home directory restrictions: deny Read on everything in ~/
381
+ // that is not an ancestor of the working directory. This prevents
382
+ // Claude from reading SSH keys, credentials, etc. `Read(~/**)` does
383
+ // not work as a disallowedTools pattern — `~` is not expanded to the
384
+ // home directory path, so the pattern never matches.
385
+ const homeDisallowedTools = this.config.workingDirectory
386
+ ? buildHomeDirectoryDisallowedTools(this.config.workingDirectory, this.config.allowedDirectories ?? [])
387
+ : [];
388
+ // Merge config-level denials with home directory denials, deduplicating in case
389
+ // any paths appear in both (e.g. an allowedDirectory that is also explicitly denied).
390
+ const processedDisallowedTools = [
391
+ ...new Set([
392
+ ...(this.config.disallowedTools ?? []),
393
+ ...homeDisallowedTools,
394
+ ]),
395
+ ];
273
396
  // Log disallowed tools if configured
274
- if (processedDisallowedTools) {
397
+ if (processedDisallowedTools.length > 0) {
275
398
  this.logger.debug("Disallowed tools configured:", processedDisallowedTools);
276
399
  }
277
400
  // Parse MCP config - merge file(s) and inline configs
@@ -308,14 +431,7 @@ export class ClaudeRunner extends EventEmitter {
308
431
  const mcpConfigContent = readFileSync(path, "utf8");
309
432
  const mcpConfig = JSON.parse(mcpConfigContent);
310
433
  const servers = mcpConfig.mcpServers || {};
311
- // Normalize transport type for file-loaded configs.
312
- // Config files (.mcp.json, mcp-*.json) often omit the `type` field,
313
- // but the SDK requires an explicit discriminator for non-stdio transports.
314
- for (const config of Object.values(servers)) {
315
- if (!config.type && typeof config.url === "string") {
316
- config.type = "http";
317
- }
318
- }
434
+ normalizeMcpHttpTransport(servers);
319
435
  mcpServers = { ...mcpServers, ...servers };
320
436
  this.logger.debug(`Loaded MCP servers from ${path}: ${Object.keys(servers).join(", ")}`);
321
437
  }
@@ -332,10 +448,7 @@ export class ClaudeRunner extends EventEmitter {
332
448
  if (this.config.allowedDirectories) {
333
449
  this.logger.debug("Allowed directories configured:", this.config.allowedDirectories);
334
450
  }
335
- // Resolve pathToClaudeCodeExecutable: use config value if provided,
336
- // otherwise auto-resolve to fix issues in symlinked environments (CYPACK-762)
337
- const pathToClaudeCodeExecutable = this.config.pathToClaudeCodeExecutable ||
338
- resolveClaudeCodeExecutablePath();
451
+ const pathToClaudeCodeExecutable = this.config.pathToClaudeCodeExecutable;
339
452
  // On Linux, setting CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 causes the SDK
340
453
  // to run tool invocations under a bubblewrap-backed sandbox. If the
341
454
  // host lacks `socat`, `bubblewrap`, or the kernel/AppArmor config
@@ -345,6 +458,7 @@ export class ClaudeRunner extends EventEmitter {
345
458
  // instead of failing opaquely mid-session.
346
459
  const sandboxRequirements = checkLinuxSandboxRequirements();
347
460
  logSandboxRequirementFailures(sandboxRequirements, this.logger);
461
+ const isDebugLogging = this.logger.getLevel() === LogLevel.DEBUG;
348
462
  const queryOptions = {
349
463
  prompt: promptForQuery,
350
464
  options: {
@@ -365,21 +479,7 @@ export class ClaudeRunner extends EventEmitter {
365
479
  // see: https://docs.claude.com/en/docs/claude-code/sdk/migration-guide#settings-sources-no-longer-loaded-by-default
366
480
  settingSources: ["user", "project", "local"],
367
481
  env: {
368
- // we continue, for now, to overlay the entirely of process.env since claude-agent-sdk stopped overlaying it again (reverted)
369
- ...process.env,
370
- ...(process.env.PATH && { PATH: process.env.PATH }),
371
- // Forward auth credentials from parent process — the SDK needs
372
- // these for API calls.
373
- // See: https://code.claude.com/docs/en/env-vars
374
- ...(process.env.ANTHROPIC_API_KEY && {
375
- ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
376
- }),
377
- ...(process.env.CLAUDE_CODE_OAUTH_TOKEN && {
378
- CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
379
- }),
380
- ...(process.env.ANTHROPIC_AUTH_TOKEN && {
381
- ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
382
- }),
482
+ ...buildBaseSessionEnv(),
383
483
  // CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is intentionally NOT set while
384
484
  // the Linux bubblewrap sandbox side effects it triggers are being
385
485
  // investigated. The sandbox requirements precheck is still run
@@ -387,9 +487,9 @@ export class ClaudeRunner extends EventEmitter {
387
487
  // See: CYPACK-1108.
388
488
  ...this.repositoryEnv,
389
489
  ...this.config.additionalEnv,
390
- CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1",
391
- CLAUDE_CODE_ENABLE_TASKS: "true",
392
- CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1",
490
+ // When logging at DEBUG level, enable the SDK's own debug output so
491
+ // --debug-to-stderr and DEBUG=1 propagate to the Claude subprocess.
492
+ ...(isDebugLogging && { DEBUG_CLAUDE_AGENT_SDK: "1" }),
393
493
  },
394
494
  ...(this.config.workingDirectory && {
395
495
  cwd: this.config.workingDirectory,
@@ -398,7 +498,7 @@ export class ClaudeRunner extends EventEmitter {
398
498
  allowedDirectories: this.config.allowedDirectories,
399
499
  }),
400
500
  ...(processedAllowedTools && { allowedTools: processedAllowedTools }),
401
- ...(processedDisallowedTools && {
501
+ ...(processedDisallowedTools.length > 0 && {
402
502
  disallowedTools: processedDisallowedTools,
403
503
  }),
404
504
  ...(this.canUseToolCallback && {
@@ -407,6 +507,9 @@ export class ClaudeRunner extends EventEmitter {
407
507
  ...(this.config.resumeSessionId && {
408
508
  resume: this.config.resumeSessionId,
409
509
  }),
510
+ ...(this.config.sessionStore && {
511
+ sessionStore: this.config.sessionStore,
512
+ }),
410
513
  ...(Object.keys(mcpServers).length > 0 && { mcpServers }),
411
514
  ...(this.config.hooks && { hooks: this.config.hooks }),
412
515
  ...(this.config.plugins?.length && { plugins: this.config.plugins }),
@@ -420,8 +523,35 @@ export class ClaudeRunner extends EventEmitter {
420
523
  ...(pathToClaudeCodeExecutable && { pathToClaudeCodeExecutable }),
421
524
  },
422
525
  };
526
+ // Local DEBUG console keeps the full untruncated payload — useful
527
+ // when troubleshooting on the host machine where secrets aren't an
528
+ // issue.
529
+ if (isDebugLogging) {
530
+ const serializedQueryOptions = JSON.stringify(queryOptions, serializeQueryOptionsReplacer, 2);
531
+ this.logger.debug(`Claude query options: ${serializedQueryOptions}`);
532
+ }
533
+ // What ships to Sentry is a flattened set of primitive attributes,
534
+ // not a single nested-JSON string. A long JSON value attached
535
+ // under a single key (we tried `options`) gets pattern-matched by
536
+ // Sentry's server-side scrubber and replaced with `[Filtered]`,
537
+ // wiping the entire diagnostic payload. Sending each datum as its
538
+ // own short, primitive attribute avoids that — short non-credential
539
+ // values don't trip the matcher, and a per-key filter (if it ever
540
+ // fires) only loses one attribute, not the whole payload.
541
+ const flat = flattenSanitizedQueryOptions(buildSanitizedQueryOptions(queryOptions));
542
+ this.logger.event("claude_query_options", flat);
423
543
  // Process messages from the query
424
- for await (const message of query(queryOptions)) {
544
+ // Use pre-warmed session if available (eliminates cold-start subprocess spawn cost).
545
+ // warmSession.query() accepts both string and AsyncIterable<SDKUserMessage>,
546
+ // so promptForQuery works correctly for both start() and startStreaming().
547
+ if (this.config.warmSession) {
548
+ this.logger.debug("Using pre-warmed session for first turn");
549
+ this.activeQuery = this.config.warmSession.query(promptForQuery);
550
+ }
551
+ else {
552
+ this.activeQuery = query(queryOptions);
553
+ }
554
+ for await (const message of this.activeQuery) {
425
555
  if (!this.sessionInfo?.isRunning) {
426
556
  this.logger.info("Session was stopped, breaking from query loop");
427
557
  break;
@@ -429,7 +559,9 @@ export class ClaudeRunner extends EventEmitter {
429
559
  // Extract session ID from first message if we don't have one yet
430
560
  if (!this.sessionInfo.sessionId && message.session_id) {
431
561
  this.sessionInfo.sessionId = message.session_id;
432
- this.logger.info(`Session ID assigned by Claude: ${message.session_id}`);
562
+ this.logger.event("claude_session_id_assigned", {
563
+ claudeSessionId: message.session_id,
564
+ });
433
565
  // Update streaming prompt with session ID if it exists
434
566
  if (this.streamingPrompt) {
435
567
  this.streamingPrompt.updateSessionId(message.session_id);
@@ -451,25 +583,30 @@ export class ClaudeRunner extends EventEmitter {
451
583
  if (this.readableLogStream) {
452
584
  this.writeReadableLogEntry(message);
453
585
  }
454
- // Emit appropriate events based on message type
455
- // Defer result message emission until after loop completes to avoid race conditions
456
- // where subroutine transitions start before the runner has fully cleaned up
457
- if (message.type === "result") {
458
- this.pendingResultMessage = message;
459
- // Complete streaming prompt immediately so it stops accepting input
460
- if (this.streamingPrompt) {
461
- this.logger.debug("Got result message, completing streaming prompt");
462
- this.streamingPrompt.complete();
463
- }
464
- }
465
- else {
466
- this.emit("message", message);
467
- this.processMessage(message);
586
+ // Emit all messages (including result) immediately in-loop.
587
+ // When keepSessionWarm is true, the streamingPrompt stays open for
588
+ // follow-up messages so the SDK session can be reused. Otherwise we
589
+ // complete the streaming prompt on result so the for-await loop exits
590
+ // and the subprocess can shut down (pre-warm-sessions behavior).
591
+ this.logger.event("message_emitted", {
592
+ messageType: message.type,
593
+ claudeSessionId: this.sessionInfo?.sessionId,
594
+ });
595
+ this.emit("message", message);
596
+ this.processMessage(message);
597
+ if (message.type === "result" &&
598
+ !this.keepSessionWarm &&
599
+ this.streamingPrompt) {
600
+ this.streamingPrompt.complete();
468
601
  }
469
602
  }
603
+ this.activeQuery = null;
470
604
  // Session completed successfully - mark as not running BEFORE emitting result
471
605
  // This ensures any code checking isRunning() during result processing sees the correct state
472
- this.logger.info(`Session completed with ${this.messages.length} messages`);
606
+ this.logger.event("session_completed", {
607
+ messageCount: this.messages.length,
608
+ claudeSessionId: this.sessionInfo?.sessionId,
609
+ });
473
610
  this.sessionInfo.isRunning = false;
474
611
  // Emit deferred result message after marking isRunning = false
475
612
  if (this.pendingResultMessage) {
@@ -495,10 +632,16 @@ export class ClaudeRunner extends EventEmitter {
495
632
  error.message.includes("Claude Code process exited with code 143");
496
633
  if (isAbortError) {
497
634
  // User-initiated stop - log at info level, not error
498
- this.logger.info("Session stopped by user");
635
+ this.logger.event("session_stopped", {
636
+ reason: "user_abort",
637
+ claudeSessionId: this.sessionInfo?.sessionId,
638
+ });
499
639
  }
500
640
  else if (isSigterm) {
501
- this.logger.info("Session was terminated gracefully (SIGTERM)");
641
+ this.logger.event("session_stopped", {
642
+ reason: "sigterm",
643
+ claudeSessionId: this.sessionInfo?.sessionId,
644
+ });
502
645
  }
503
646
  else {
504
647
  // Actual error - log and emit
@@ -509,6 +652,7 @@ export class ClaudeRunner extends EventEmitter {
509
652
  finally {
510
653
  // Clean up
511
654
  this.abortController = null;
655
+ this.activeQuery = null;
512
656
  this.pendingResultMessage = null;
513
657
  // Complete and clean up streaming prompt if it exists
514
658
  if (this.streamingPrompt) {
@@ -563,12 +707,44 @@ export class ClaudeRunner extends EventEmitter {
563
707
  }
564
708
  }
565
709
  }
710
+ /**
711
+ * Interrupt the current turn without killing the session.
712
+ * The session stays warm and can accept new messages.
713
+ *
714
+ * Only safe to call on warm sessions (see {@link isWarm}). Calling
715
+ * `interrupt()` on a non-warm session aborts the underlying request and
716
+ * causes the SDK to emit a "Request was aborted" error. Callers should
717
+ * gate on `isWarm()` and prefer `stop()` for non-warm sessions.
718
+ */
719
+ async interrupt() {
720
+ if (!this.keepSessionWarm) {
721
+ this.logger.debug("interrupt() called on non-warm session; falling back to stop()");
722
+ this.stop();
723
+ return;
724
+ }
725
+ if (this.activeQuery) {
726
+ this.logger.info("Interrupting current turn");
727
+ await this.activeQuery.interrupt();
728
+ }
729
+ else {
730
+ this.logger.debug("interrupt() called but no active query");
731
+ }
732
+ }
733
+ /**
734
+ * Whether this runner keeps its SDK session warm between turns. Warm
735
+ * sessions can be safely interrupted; non-warm sessions cannot.
736
+ */
737
+ isWarm() {
738
+ return this.keepSessionWarm;
739
+ }
566
740
  /**
567
741
  * Stop the current Claude session
568
742
  */
569
743
  stop() {
570
744
  if (this.abortController) {
571
- this.logger.info("Stopping session");
745
+ this.logger.event("session_stop_requested", {
746
+ claudeSessionId: this.sessionInfo?.sessionId,
747
+ });
572
748
  this.abortController.abort();
573
749
  this.abortController = null;
574
750
  }
@@ -577,6 +753,7 @@ export class ClaudeRunner extends EventEmitter {
577
753
  this.streamingPrompt.complete();
578
754
  this.streamingPrompt = null;
579
755
  }
756
+ this.activeQuery = null;
580
757
  if (this.sessionInfo) {
581
758
  this.sessionInfo.isRunning = false;
582
759
  }