bopodev-agent-sdk 0.1.9 → 0.1.11

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/src/runtime.ts CHANGED
@@ -5,6 +5,35 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { AgentRuntimeConfig } from "./types";
7
7
 
8
+ type LocalProvider = "claude_code" | "codex" | "cursor" | "opencode";
9
+ type ClaudeContractDiagnostics = {
10
+ commandOverride: boolean;
11
+ commandLooksClaude: boolean;
12
+ commandWasProviderAlias: boolean;
13
+ hasPromptFlag: boolean;
14
+ hasOutputFormatJson: boolean;
15
+ outputFormat: string | null;
16
+ hasMaxTurnsFlag: boolean;
17
+ hasVerboseFlag: boolean;
18
+ hasDangerouslySkipPermissions: boolean;
19
+ hasJsonSchema: boolean;
20
+ missingRequiredArgs: string[];
21
+ };
22
+
23
+ type ParsedUsageRecord = {
24
+ tokenInput?: number;
25
+ tokenOutput?: number;
26
+ usdCost?: number;
27
+ summary?: string;
28
+ };
29
+
30
+ type CursorParsedStream = {
31
+ usage: ParsedUsageRecord;
32
+ sessionId?: string;
33
+ errorMessage?: string;
34
+ resultSubtype?: string;
35
+ };
36
+
8
37
  export interface RuntimeExecutionOutput {
9
38
  ok: boolean;
10
39
  code: number | null;
@@ -21,6 +50,36 @@ export interface RuntimeExecutionOutput {
21
50
  usdCost?: number;
22
51
  summary?: string;
23
52
  };
53
+ structuredOutputSource?: "stdout" | "stderr";
54
+ structuredOutputDiagnostics?: {
55
+ stdoutJsonObjectCount: number;
56
+ stderrJsonObjectCount: number;
57
+ stderrStructuredUsageDetected: boolean;
58
+ stdoutBytes: number;
59
+ stderrBytes: number;
60
+ hasAnyOutput: boolean;
61
+ lastStdoutLine?: string;
62
+ lastStderrLine?: string;
63
+ likelyCause:
64
+ | "no_output_from_runtime"
65
+ | "json_missing"
66
+ | "json_on_stderr_only"
67
+ | "schema_or_shape_mismatch";
68
+ claudeStopReason?: string;
69
+ claudeResultSubtype?: string;
70
+ claudeSessionId?: string;
71
+ claudeContract?: ClaudeContractDiagnostics;
72
+ };
73
+ commandUsed?: string;
74
+ argsUsed?: string[];
75
+ transcript?: RuntimeTranscriptEvent[];
76
+ }
77
+
78
+ export interface RuntimeTranscriptEvent {
79
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
80
+ label?: string;
81
+ text?: string;
82
+ payload?: string;
24
83
  }
25
84
 
26
85
  export interface RuntimeAttemptTrace {
@@ -50,7 +109,16 @@ function pickDefaultCommand(provider: "claude_code" | "codex") {
50
109
 
51
110
  function providerDefaultArgs(provider: "claude_code" | "codex", config?: AgentRuntimeConfig) {
52
111
  if (provider === "claude_code") {
53
- return ["-p"];
112
+ return [
113
+ "--print",
114
+ "-",
115
+ "--output-format",
116
+ "stream-json",
117
+ "--verbose",
118
+ "--max-turns",
119
+ "8",
120
+ "--dangerously-skip-permissions"
121
+ ];
54
122
  }
55
123
  // Keep Codex non-interactive, sandboxed, and writable in-workspace by default.
56
124
  // Codex CLI rejects combining --full-auto with sandbox bypass flags.
@@ -92,6 +160,13 @@ function providerConfigArgs(provider: "claude_code" | "codex", config?: AgentRun
92
160
  return args;
93
161
  }
94
162
 
163
+ function resolveControlPlaneEnvValue(env: NodeJS.ProcessEnv | Record<string, string> | undefined, suffix: string) {
164
+ if (!env) {
165
+ return "";
166
+ }
167
+ return String(env[`BOPODEV_${suffix}`] ?? "").trim();
168
+ }
169
+
95
170
  function shouldBypassCodexSandbox(config?: AgentRuntimeConfig) {
96
171
  if (config?.runPolicy?.sandboxMode === "full_access") {
97
172
  return true;
@@ -101,16 +176,12 @@ function shouldBypassCodexSandbox(config?: AgentRuntimeConfig) {
101
176
  return false;
102
177
  }
103
178
  const hasControlPlaneContext =
104
- typeof env.BOPOHQ_API_BASE_URL === "string" &&
105
- env.BOPOHQ_API_BASE_URL.trim().length > 0 &&
106
- typeof env.BOPOHQ_REQUEST_HEADERS_JSON === "string" &&
107
- env.BOPOHQ_REQUEST_HEADERS_JSON.trim().length > 0;
179
+ resolveControlPlaneEnvValue(env, "API_BASE_URL").length > 0 &&
180
+ resolveControlPlaneEnvValue(env, "REQUEST_HEADERS_JSON").length > 0;
108
181
  if (!hasControlPlaneContext) {
109
182
  return false;
110
183
  }
111
- const enforceSandbox = String(env.BOPOHQ_ENFORCE_SANDBOX ?? "")
112
- .trim()
113
- .toLowerCase();
184
+ const enforceSandbox = resolveControlPlaneEnvValue(env, "ENFORCE_SANDBOX").toLowerCase();
114
185
  if (enforceSandbox === "1" || enforceSandbox === "true") {
115
186
  return false;
116
187
  }
@@ -122,31 +193,89 @@ export async function executeAgentRuntime(
122
193
  prompt: string,
123
194
  config?: AgentRuntimeConfig
124
195
  ): Promise<RuntimeExecutionOutput> {
196
+ const command = resolveProviderCommand(provider, config?.command);
125
197
  const commandOverride = Boolean(config?.command && config.command.trim().length > 0);
126
198
  const effectiveRetryCount = config?.retryCount ?? (provider === "codex" ? 1 : 0);
127
- const mergedArgs = [
199
+ const candidateArgs = [
128
200
  ...(commandOverride ? [] : providerDefaultArgs(provider, config)),
129
201
  ...(commandOverride ? [] : providerConfigArgs(provider, config)),
130
202
  ...(config?.args ?? [])
131
203
  ];
132
- return executePromptRuntime(
133
- config?.command ?? pickDefaultCommand(provider),
204
+ const mergedArgs =
205
+ provider === "claude_code"
206
+ ? ensureClaudeStructuredOutputArgs(command, candidateArgs)
207
+ : candidateArgs;
208
+ let runtime = await executePromptRuntime(
209
+ command,
134
210
  prompt,
135
- { ...config, args: mergedArgs, retryCount: effectiveRetryCount },
136
211
  {
137
- provider
212
+ ...config,
213
+ args: mergedArgs,
214
+ retryCount: effectiveRetryCount,
215
+ timeoutMs: config?.timeoutMs ?? defaultProviderTimeoutMs(provider)
216
+ },
217
+ {
218
+ provider,
219
+ claudeContract:
220
+ provider === "claude_code" ? inspectClaudeOutputContract(command, mergedArgs, commandOverride) : undefined
138
221
  }
139
222
  );
223
+ if (provider !== "claude_code") {
224
+ return runtime;
225
+ }
226
+
227
+ const maxContinuationAttempts = 2;
228
+ let continuationArgs = [...mergedArgs];
229
+ for (let continuation = 0; continuation < maxContinuationAttempts; continuation += 1) {
230
+ if (!runtime.ok) {
231
+ break;
232
+ }
233
+ if (!isClaudeMaxTurnsRuntime(runtime)) {
234
+ break;
235
+ }
236
+ const sessionId = runtime.structuredOutputDiagnostics?.claudeSessionId?.trim();
237
+ if (!sessionId) {
238
+ break;
239
+ }
240
+ continuationArgs = withClaudeResumeArg(continuationArgs, sessionId);
241
+ runtime = await executePromptRuntime(
242
+ command,
243
+ "Continue from current session and finish all remaining assigned issue steps.",
244
+ {
245
+ ...config,
246
+ args: continuationArgs,
247
+ retryCount: 0,
248
+ timeoutMs: config?.timeoutMs ?? defaultProviderTimeoutMs(provider)
249
+ },
250
+ {
251
+ provider,
252
+ claudeContract: inspectClaudeOutputContract(command, continuationArgs, true)
253
+ }
254
+ );
255
+ }
256
+
257
+ return runtime;
258
+ }
259
+
260
+ function defaultProviderTimeoutMs(provider: "claude_code" | "codex") {
261
+ if (provider === "claude_code") {
262
+ return 90 * 1000;
263
+ }
264
+ return 5 * 60 * 1000;
140
265
  }
141
266
 
142
267
  export async function executePromptRuntime(
143
268
  command: string,
144
269
  prompt: string,
145
270
  config?: AgentRuntimeConfig,
146
- options?: { provider?: "claude_code" | "codex" }
271
+ options?: {
272
+ provider?: LocalProvider;
273
+ claudeContract?: ClaudeContractDiagnostics;
274
+ }
147
275
  ): Promise<RuntimeExecutionOutput> {
148
276
  const baseArgs = [...(config?.args ?? [])];
149
277
  const timeoutMs = config?.timeoutMs ?? 120_000;
278
+ const abortSignal = config?.abortSignal;
150
279
  const interruptGraceMs = Math.max(0, config?.interruptGraceSec ?? 2) * 1000;
151
280
  const maxAttempts = Math.max(1, Math.min(3, 1 + (config?.retryCount ?? 0)));
152
281
  const retryBackoffMs = Math.max(100, config?.retryBackoffMs ?? 400);
@@ -159,7 +288,11 @@ export async function executePromptRuntime(
159
288
  const env = providerIsolation.env;
160
289
  const provider = options?.provider;
161
290
  const injection = await prepareSkillInjection(provider, env);
162
- const args = [...baseArgs, ...injection.additionalArgs, prompt];
291
+ const baseWithInjection = [...baseArgs, ...injection.additionalArgs];
292
+ const readsPromptFromStdin =
293
+ (provider === "claude_code" && hasCliFlagValue(baseWithInjection, "--print", "-")) ||
294
+ (provider === "cursor" && (baseWithInjection.includes("-p") || hasCliFlag(baseWithInjection, "--print")));
295
+ const args = readsPromptFromStdin ? baseWithInjection : [...baseWithInjection, prompt];
163
296
  const attempts: RuntimeAttemptTrace[] = [];
164
297
  let stdout = "";
165
298
  let stderr = injection.warning ? `${injection.warning}\n` : "";
@@ -178,10 +311,12 @@ export async function executePromptRuntime(
178
311
  const attemptResult = await executeSinglePromptAttempt(
179
312
  command,
180
313
  args,
314
+ readsPromptFromStdin ? prompt : undefined,
181
315
  config?.cwd || process.cwd(),
182
316
  env,
183
317
  timeoutMs,
184
- interruptGraceMs
318
+ interruptGraceMs,
319
+ abortSignal
185
320
  );
186
321
  const normalizedStderr = provider === "codex" ? stripCodexRolloutNoise(attemptResult.stderr) : attemptResult.stderr;
187
322
  stdout += attemptResult.stdout;
@@ -205,6 +340,10 @@ export async function executePromptRuntime(
205
340
  };
206
341
 
207
342
  if (attemptResult.ok) {
343
+ const claudeStream = provider === "claude_code" ? parseClaudeStreamOutput(stdout) : undefined;
344
+ const cursorStream = provider === "cursor" ? parseCursorStreamOutput(stdout) : undefined;
345
+ const stdoutUsage = cursorStream?.usage ?? claudeStream?.usage ?? parseStructuredUsage(stdout);
346
+ const stderrUsage = parseStructuredUsage(stderr);
208
347
  return {
209
348
  ok: true,
210
349
  code: attemptResult.code,
@@ -214,7 +353,28 @@ export async function executePromptRuntime(
214
353
  elapsedMs: attempts.reduce((sum, item) => sum + item.elapsedMs, 0),
215
354
  attemptCount: attempts.length,
216
355
  attempts,
217
- parsedUsage: parseStructuredUsage(stdout)
356
+ parsedUsage: stdoutUsage ?? stderrUsage,
357
+ structuredOutputSource: stdoutUsage ? "stdout" : stderrUsage ? "stderr" : undefined,
358
+ structuredOutputDiagnostics: {
359
+ stdoutJsonObjectCount: extractJsonObjectBlocks(stdout).length,
360
+ stderrJsonObjectCount: extractJsonObjectBlocks(stderr).length,
361
+ stderrStructuredUsageDetected: Boolean(stderrUsage),
362
+ stdoutBytes: Buffer.byteLength(stdout, "utf8"),
363
+ stderrBytes: Buffer.byteLength(stderr, "utf8"),
364
+ hasAnyOutput: stdout.trim().length > 0 || stderr.trim().length > 0,
365
+ lastStdoutLine: tailLine(stdout),
366
+ lastStderrLine: tailLine(stderr),
367
+ likelyCause: classifyStructuredOutputLikelyCause(stdout, stderr, stdoutUsage, stderrUsage),
368
+ ...(claudeStream?.stopReason ? { claudeStopReason: claudeStream.stopReason } : {}),
369
+ ...(claudeStream?.resultSubtype ? { claudeResultSubtype: claudeStream.resultSubtype } : {}),
370
+ ...(claudeStream?.sessionId ? { claudeSessionId: claudeStream.sessionId } : {}),
371
+ ...(cursorStream?.sessionId ? { cursorSessionId: cursorStream.sessionId } : {}),
372
+ ...(cursorStream?.errorMessage ? { cursorErrorMessage: cursorStream.errorMessage } : {}),
373
+ ...(options?.claudeContract ? { claudeContract: options.claudeContract } : {})
374
+ },
375
+ commandUsed: command,
376
+ argsUsed: args,
377
+ transcript: parseRuntimeTranscript(provider, stdout, stderr)
218
378
  };
219
379
  }
220
380
 
@@ -234,6 +394,10 @@ export async function executePromptRuntime(
234
394
  await sleep(retryBackoffMs * attempt);
235
395
  }
236
396
 
397
+ const claudeStream = provider === "claude_code" ? parseClaudeStreamOutput(stdout) : undefined;
398
+ const cursorStream = provider === "cursor" ? parseCursorStreamOutput(stdout) : undefined;
399
+ const stdoutUsage = cursorStream?.usage ?? claudeStream?.usage ?? parseStructuredUsage(stdout);
400
+ const stderrUsage = parseStructuredUsage(stderr);
237
401
  return {
238
402
  ok: false,
239
403
  code: lastResult?.code ?? null,
@@ -244,7 +408,28 @@ export async function executePromptRuntime(
244
408
  attemptCount: attempts.length,
245
409
  attempts,
246
410
  failureType: classifyFailure(lastResult?.timedOut ?? false, lastResult?.spawnErrorCode, lastResult?.code ?? null),
247
- parsedUsage: parseStructuredUsage(stdout)
411
+ parsedUsage: stdoutUsage ?? stderrUsage,
412
+ structuredOutputSource: stdoutUsage ? "stdout" : stderrUsage ? "stderr" : undefined,
413
+ structuredOutputDiagnostics: {
414
+ stdoutJsonObjectCount: extractJsonObjectBlocks(stdout).length,
415
+ stderrJsonObjectCount: extractJsonObjectBlocks(stderr).length,
416
+ stderrStructuredUsageDetected: Boolean(stderrUsage),
417
+ stdoutBytes: Buffer.byteLength(stdout, "utf8"),
418
+ stderrBytes: Buffer.byteLength(stderr, "utf8"),
419
+ hasAnyOutput: stdout.trim().length > 0 || stderr.trim().length > 0,
420
+ lastStdoutLine: tailLine(stdout),
421
+ lastStderrLine: tailLine(stderr),
422
+ likelyCause: classifyStructuredOutputLikelyCause(stdout, stderr, stdoutUsage, stderrUsage),
423
+ ...(claudeStream?.stopReason ? { claudeStopReason: claudeStream.stopReason } : {}),
424
+ ...(claudeStream?.resultSubtype ? { claudeResultSubtype: claudeStream.resultSubtype } : {}),
425
+ ...(claudeStream?.sessionId ? { claudeSessionId: claudeStream.sessionId } : {}),
426
+ ...(cursorStream?.sessionId ? { cursorSessionId: cursorStream.sessionId } : {}),
427
+ ...(cursorStream?.errorMessage ? { cursorErrorMessage: cursorStream.errorMessage } : {}),
428
+ ...(options?.claudeContract ? { claudeContract: options.claudeContract } : {})
429
+ },
430
+ commandUsed: command,
431
+ argsUsed: args,
432
+ transcript: parseRuntimeTranscript(provider, stdout, stderr)
248
433
  };
249
434
  } finally {
250
435
  await providerIsolation.cleanup();
@@ -252,6 +437,159 @@ export async function executePromptRuntime(
252
437
  }
253
438
  }
254
439
 
440
+ function ensureClaudeStructuredOutputArgs(command: string, args: string[]) {
441
+ const contract = inspectClaudeOutputContract(command, args, true);
442
+ if (!contract.commandLooksClaude) {
443
+ return args;
444
+ }
445
+ const next = [...args];
446
+ if (!contract.hasPromptFlag) {
447
+ next.push("--print", "-");
448
+ }
449
+ if (!contract.hasOutputFormatJson) {
450
+ next.push("--output-format", "stream-json");
451
+ }
452
+ if (contract.outputFormat === "json" && !contract.hasJsonSchema) {
453
+ next.push("--json-schema", CLAUDE_HEARTBEAT_OUTPUT_SCHEMA);
454
+ }
455
+ if (!contract.hasMaxTurnsFlag) {
456
+ next.push("--max-turns", "8");
457
+ }
458
+ if (!contract.hasVerboseFlag) {
459
+ next.push("--verbose");
460
+ }
461
+ if (!contract.hasDangerouslySkipPermissions) {
462
+ next.push("--dangerously-skip-permissions");
463
+ }
464
+ return next;
465
+ }
466
+
467
+ function inspectClaudeOutputContract(command: string, args: string[], commandOverride: boolean) {
468
+ const commandToken = command.trim().split(/[\\/]/).pop() ?? "";
469
+ const commandLooksClaude = /\bclaude(?:\.exe)?$/i.test(commandToken);
470
+ const commandWasProviderAlias = /^claude_code$/i.test(commandToken);
471
+ const hasPromptFlag = args.includes("-p") || hasCliFlagValue(args, "--print", "-");
472
+ const outputFormat = resolveCliFlagValue(args, "--output-format");
473
+ const hasOutputFormatJson = outputFormat === "json" || outputFormat === "stream-json";
474
+ const hasMaxTurnsFlag = hasCliFlag(args, "--max-turns");
475
+ const hasVerboseFlag = hasCliFlag(args, "--verbose");
476
+ const hasDangerouslySkipPermissions = hasCliFlag(args, "--dangerously-skip-permissions");
477
+ const hasJsonSchema = hasCliFlag(args, "--json-schema");
478
+ const missingRequiredArgs: string[] = [];
479
+ if (!hasPromptFlag) {
480
+ missingRequiredArgs.push("--print -");
481
+ }
482
+ if (!hasOutputFormatJson) {
483
+ missingRequiredArgs.push("--output-format stream-json");
484
+ }
485
+ if (outputFormat === "json" && !hasJsonSchema) {
486
+ missingRequiredArgs.push("--json-schema <heartbeat-schema>");
487
+ }
488
+ if (!hasMaxTurnsFlag) {
489
+ missingRequiredArgs.push("--max-turns 8");
490
+ }
491
+ if (!hasVerboseFlag) {
492
+ missingRequiredArgs.push("--verbose");
493
+ }
494
+ if (!hasDangerouslySkipPermissions) {
495
+ missingRequiredArgs.push("--dangerously-skip-permissions");
496
+ }
497
+ return {
498
+ commandOverride,
499
+ commandLooksClaude,
500
+ commandWasProviderAlias,
501
+ hasPromptFlag,
502
+ hasOutputFormatJson,
503
+ outputFormat,
504
+ hasMaxTurnsFlag,
505
+ hasVerboseFlag,
506
+ hasDangerouslySkipPermissions,
507
+ hasJsonSchema,
508
+ missingRequiredArgs
509
+ };
510
+ }
511
+
512
+ function resolveProviderCommand(provider: "claude_code" | "codex", configuredCommand: string | undefined) {
513
+ const trimmed = configuredCommand?.trim();
514
+ if (!trimmed) {
515
+ return pickDefaultCommand(provider);
516
+ }
517
+ // Normalize accidental provider id aliases used as command strings.
518
+ if (provider === "claude_code" && trimmed === "claude_code") {
519
+ return "claude";
520
+ }
521
+ return trimmed;
522
+ }
523
+
524
+ function withClaudeResumeArg(args: string[], sessionId: string) {
525
+ const next: string[] = [];
526
+ for (let index = 0; index < args.length; index += 1) {
527
+ const current = args[index] ?? "";
528
+ if (current === "--resume") {
529
+ index += 1;
530
+ continue;
531
+ }
532
+ if (current.startsWith("--resume=")) {
533
+ continue;
534
+ }
535
+ next.push(current);
536
+ }
537
+ next.push("--resume", sessionId);
538
+ return next;
539
+ }
540
+
541
+ function isClaudeMaxTurnsRuntime(runtime: RuntimeExecutionOutput) {
542
+ return (
543
+ runtime.structuredOutputDiagnostics?.claudeStopReason === "max_turns" ||
544
+ runtime.structuredOutputDiagnostics?.claudeResultSubtype === "error_max_turns"
545
+ );
546
+ }
547
+
548
+ function hasCliFlag(args: string[], flag: string) {
549
+ return args.some((arg, index) => arg === flag || (arg.startsWith(`${flag}=`) && index >= 0));
550
+ }
551
+
552
+ function resolveCliFlagValue(args: string[], flag: string) {
553
+ for (let index = 0; index < args.length; index += 1) {
554
+ const current = (args[index] ?? "").trim();
555
+ if (!current) {
556
+ continue;
557
+ }
558
+ if (current === flag) {
559
+ const next = (args[index + 1] ?? "").trim();
560
+ return next || null;
561
+ }
562
+ if (current.startsWith(`${flag}=`)) {
563
+ return current.slice(flag.length + 1).trim() || null;
564
+ }
565
+ }
566
+ return null;
567
+ }
568
+
569
+ function hasCliFlagValue(args: string[], flag: string, expectedValue: string) {
570
+ const expected = expectedValue.toLowerCase();
571
+ for (let index = 0; index < args.length; index += 1) {
572
+ const current = (args[index] ?? "").trim();
573
+ if (!current) {
574
+ continue;
575
+ }
576
+ if (current === flag) {
577
+ const next = (args[index + 1] ?? "").trim().toLowerCase();
578
+ if (next === expected) {
579
+ return true;
580
+ }
581
+ continue;
582
+ }
583
+ if (current.startsWith(`${flag}=`)) {
584
+ const inlineValue = current.slice(flag.length + 1).trim().toLowerCase();
585
+ if (inlineValue === expected) {
586
+ return true;
587
+ }
588
+ }
589
+ }
590
+ return false;
591
+ }
592
+
255
593
  const SKILLS_DIR_NAME = "skills";
256
594
  const CLAUDE_SKILLS_DIR = ".claude/skills";
257
595
  const SKILL_MD = "SKILL.md";
@@ -260,7 +598,17 @@ const DEFAULT_CODEX_HOME_FALLBACK = ".codex";
260
598
  const CODEX_VOLATILE_STATE_ENTRIES = ["rollouts", "state.db", "data/rollouts", "data/state.db"];
261
599
  const CODEX_ROLLOUT_NOISE_RE =
262
600
  /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
263
-
601
+ const CLAUDE_HEARTBEAT_OUTPUT_SCHEMA = JSON.stringify({
602
+ type: "object",
603
+ additionalProperties: false,
604
+ required: ["summary"],
605
+ properties: {
606
+ summary: { type: "string", minLength: 1 },
607
+ tokenInput: { type: "number" },
608
+ tokenOutput: { type: "number" },
609
+ usdCost: { type: "number" }
610
+ }
611
+ });
264
612
  type SkillInjectionContext = {
265
613
  additionalArgs: string[];
266
614
  warning?: string;
@@ -268,7 +616,7 @@ type SkillInjectionContext = {
268
616
  };
269
617
 
270
618
  async function prepareSkillInjection(
271
- provider: "claude_code" | "codex" | undefined,
619
+ provider: LocalProvider | undefined,
272
620
  env: NodeJS.ProcessEnv
273
621
  ): Promise<SkillInjectionContext> {
274
622
  if (!provider) {
@@ -279,7 +627,7 @@ async function prepareSkillInjection(
279
627
  if (!skillsSource) {
280
628
  return {
281
629
  ...noSkillInjection(),
282
- warning: "[bopohq] skills injection skipped: no skills directory found."
630
+ warning: "[bopodev] skills injection skipped: no skills directory found."
283
631
  };
284
632
  }
285
633
 
@@ -290,7 +638,31 @@ async function prepareSkillInjection(
290
638
  } catch (error) {
291
639
  return {
292
640
  ...noSkillInjection(),
293
- warning: `[bopohq] skills injection failed for codex: ${String(error)}`
641
+ warning: `[bopodev] skills injection failed for codex: ${String(error)}`
642
+ };
643
+ }
644
+ }
645
+
646
+ if (provider === "cursor") {
647
+ try {
648
+ await ensureSkillsInjectedAtHome(skillsSource, join(homedir(), ".cursor", "skills"));
649
+ return noSkillInjection();
650
+ } catch (error) {
651
+ return {
652
+ ...noSkillInjection(),
653
+ warning: `[bopodev] skills injection failed for cursor: ${String(error)}`
654
+ };
655
+ }
656
+ }
657
+
658
+ if (provider === "opencode") {
659
+ try {
660
+ await ensureSkillsInjectedAtHome(skillsSource, join(homedir(), ".claude", "skills"));
661
+ return noSkillInjection();
662
+ } catch (error) {
663
+ return {
664
+ ...noSkillInjection(),
665
+ warning: `[bopodev] skills injection failed for opencode: ${String(error)}`
294
666
  };
295
667
  }
296
668
  }
@@ -306,7 +678,7 @@ async function prepareSkillInjection(
306
678
  } catch (error) {
307
679
  return {
308
680
  ...noSkillInjection(),
309
- warning: `[bopohq] skills injection failed for claude_code: ${String(error)}`
681
+ warning: `[bopodev] skills injection failed for claude_code: ${String(error)}`
310
682
  };
311
683
  }
312
684
  }
@@ -322,10 +694,12 @@ const TRANSIENT_SPAWN_ERROR_CODES = new Set(["EAGAIN", "EMFILE", "ENFILE", "ETXT
322
694
  async function executeSinglePromptAttempt(
323
695
  command: string,
324
696
  args: string[],
697
+ stdinPrompt: string | undefined,
325
698
  cwd: string,
326
699
  env: NodeJS.ProcessEnv,
327
700
  timeoutMs: number,
328
- interruptGraceMs: number
701
+ interruptGraceMs: number,
702
+ abortSignal?: AbortSignal
329
703
  ) {
330
704
  const startedAt = Date.now();
331
705
  return new Promise<{
@@ -350,9 +724,10 @@ async function executeSinglePromptAttempt(
350
724
  let resolved = false;
351
725
  let timedOut = false;
352
726
  let forcedKill = false;
727
+ let abortedBySignal = false;
353
728
  let timeoutKillTimer: NodeJS.Timeout | undefined;
354
- const timeout = setTimeout(() => {
355
- timedOut = true;
729
+ let abortListener: (() => void) | undefined;
730
+ const scheduleTermination = () => {
356
731
  child.kill("SIGTERM");
357
732
  timeoutKillTimer = setTimeout(() => {
358
733
  if (!resolved) {
@@ -360,7 +735,23 @@ async function executeSinglePromptAttempt(
360
735
  child.kill("SIGKILL");
361
736
  }
362
737
  }, interruptGraceMs);
738
+ };
739
+ const timeout = setTimeout(() => {
740
+ timedOut = true;
741
+ scheduleTermination();
363
742
  }, timeoutMs);
743
+ if (abortSignal) {
744
+ abortListener = () => {
745
+ abortedBySignal = true;
746
+ timedOut = true;
747
+ scheduleTermination();
748
+ };
749
+ if (abortSignal.aborted) {
750
+ abortListener();
751
+ } else {
752
+ abortSignal.addEventListener("abort", abortListener, { once: true });
753
+ }
754
+ }
364
755
 
365
756
  child.stdout.on("data", (chunk) => {
366
757
  stdout += String(chunk);
@@ -368,6 +759,10 @@ async function executeSinglePromptAttempt(
368
759
  child.stderr.on("data", (chunk) => {
369
760
  stderr += String(chunk);
370
761
  });
762
+ if (stdinPrompt !== undefined) {
763
+ child.stdin.write(stdinPrompt);
764
+ child.stdin.end();
765
+ }
371
766
 
372
767
  child.on("close", (code, signal) => {
373
768
  if (resolved) {
@@ -378,11 +773,14 @@ async function executeSinglePromptAttempt(
378
773
  if (timeoutKillTimer) {
379
774
  clearTimeout(timeoutKillTimer);
380
775
  }
776
+ if (abortSignal && abortListener) {
777
+ abortSignal.removeEventListener("abort", abortListener);
778
+ }
381
779
  resolve({
382
780
  ok: code === 0 && !timedOut,
383
781
  code,
384
782
  stdout,
385
- stderr,
783
+ stderr: abortedBySignal ? [stderr, "Execution aborted by watchdog signal."].filter(Boolean).join("\n") : stderr,
386
784
  timedOut,
387
785
  elapsedMs: Date.now() - startedAt,
388
786
  signal,
@@ -399,6 +797,9 @@ async function executeSinglePromptAttempt(
399
797
  if (timeoutKillTimer) {
400
798
  clearTimeout(timeoutKillTimer);
401
799
  }
800
+ if (abortSignal && abortListener) {
801
+ abortSignal.removeEventListener("abort", abortListener);
802
+ }
402
803
  const errorWithCode = error as NodeJS.ErrnoException;
403
804
  resolve({
404
805
  ok: false,
@@ -542,7 +943,7 @@ function resolveCodexHome(env: NodeJS.ProcessEnv) {
542
943
  }
543
944
 
544
945
  function normalizeProviderAuthEnv(
545
- provider: "claude_code" | "codex" | undefined,
946
+ provider: LocalProvider | undefined,
546
947
  env: NodeJS.ProcessEnv
547
948
  ) {
548
949
  if (provider !== "codex") {
@@ -563,7 +964,7 @@ type ProviderIsolationContext = {
563
964
  };
564
965
 
565
966
  async function withProviderRuntimeIsolation(
566
- provider: "claude_code" | "codex" | undefined,
967
+ provider: LocalProvider | undefined,
567
968
  env: NodeJS.ProcessEnv
568
969
  ): Promise<ProviderIsolationContext> {
569
970
  if (provider !== "codex") {
@@ -572,7 +973,7 @@ async function withProviderRuntimeIsolation(
572
973
  cleanup: async () => {}
573
974
  };
574
975
  }
575
- const forceManagedCodexHome = env.BOPOHQ_FORCE_MANAGED_CODEX_HOME === "true";
976
+ const forceManagedCodexHome = resolveControlPlaneEnvValue(env, "FORCE_MANAGED_CODEX_HOME") === "true";
576
977
  if (env.CODEX_HOME?.trim() && !forceManagedCodexHome) {
577
978
  await sanitizeCodexHomeVolatileState(env.CODEX_HOME.trim());
578
979
  return {
@@ -582,7 +983,7 @@ async function withProviderRuntimeIsolation(
582
983
  }
583
984
  const hasApiKey = typeof env.OPENAI_API_KEY === "string" && env.OPENAI_API_KEY.trim().length > 0;
584
985
  if (hasApiKey) {
585
- const runScopedCodexHome = await mkdtemp(join(tmpdir(), "bopohq-codex-home-run-"));
986
+ const runScopedCodexHome = await mkdtemp(join(tmpdir(), "bopodev-codex-home-run-"));
586
987
  await sanitizeCodexHomeVolatileState(runScopedCodexHome);
587
988
  return {
588
989
  env: {
@@ -614,10 +1015,24 @@ async function withProviderRuntimeIsolation(
614
1015
  };
615
1016
  }
616
1017
 
1018
+ async function ensureSkillsInjectedAtHome(skillsSourceDir: string, targetRoot: string) {
1019
+ await mkdir(targetRoot, { recursive: true });
1020
+ const entries = await readdir(skillsSourceDir, { withFileTypes: true });
1021
+ for (const entry of entries) {
1022
+ if (!entry.isDirectory()) continue;
1023
+ const source = join(skillsSourceDir, entry.name);
1024
+ if (!(await hasSkillManifest(source))) continue;
1025
+ const target = join(targetRoot, entry.name);
1026
+ const existing = await lstat(target).catch(() => null);
1027
+ if (existing) continue;
1028
+ await symlink(source, target);
1029
+ }
1030
+ }
1031
+
617
1032
  function resolveManagedCodexHome(env: NodeJS.ProcessEnv) {
618
1033
  const managedRoot = resolveManagedCodexHomeRoot(env);
619
- const companyId = sanitizePathSegment(env.BOPOHQ_COMPANY_ID);
620
- const agentId = sanitizePathSegment(env.BOPOHQ_AGENT_ID);
1034
+ const companyId = sanitizePathSegment(resolveControlPlaneEnvValue(env, "COMPANY_ID"));
1035
+ const agentId = sanitizePathSegment(resolveControlPlaneEnvValue(env, "AGENT_ID"));
621
1036
  if (companyId && agentId) {
622
1037
  return join(managedRoot, companyId, agentId);
623
1038
  }
@@ -629,7 +1044,7 @@ function resolveManagedCodexHomeRoot(env: NodeJS.ProcessEnv) {
629
1044
  if (configuredRoot) {
630
1045
  return configuredRoot;
631
1046
  }
632
- return join(tmpdir(), "bopohq-codex-home");
1047
+ return join(tmpdir(), "bopodev-codex-home");
633
1048
  }
634
1049
 
635
1050
  async function prepareManagedCodexHome(targetCodexHome: string, env: NodeJS.ProcessEnv) {
@@ -690,7 +1105,7 @@ function sanitizePathSegment(value: string | undefined) {
690
1105
  }
691
1106
 
692
1107
  async function buildClaudeSkillsAddDir(skillsSourceDir: string) {
693
- const tempRoot = await mkdtemp(join(tmpdir(), "bopohq-skills-"));
1108
+ const tempRoot = await mkdtemp(join(tmpdir(), "bopodev-skills-"));
694
1109
  const skillsTargetDir = join(tempRoot, CLAUDE_SKILLS_DIR);
695
1110
  await mkdir(skillsTargetDir, { recursive: true });
696
1111
 
@@ -728,6 +1143,22 @@ async function fileExists(path: string) {
728
1143
  }
729
1144
 
730
1145
  function parseStructuredUsage(stdout: string) {
1146
+ const whole = stdout.trim();
1147
+ if (whole.startsWith("{") && whole.endsWith("}")) {
1148
+ const parsedWhole = tryParseUsage(whole);
1149
+ if (parsedWhole) {
1150
+ return parsedWhole;
1151
+ }
1152
+ }
1153
+
1154
+ const jsonBlocks = extractJsonObjectBlocks(stdout);
1155
+ for (let index = jsonBlocks.length - 1; index >= 0; index -= 1) {
1156
+ const parsedBlock = tryParseUsage(jsonBlocks[index] ?? "");
1157
+ if (parsedBlock) {
1158
+ return parsedBlock;
1159
+ }
1160
+ }
1161
+
731
1162
  const lines = stdout
732
1163
  .split(/\r?\n/)
733
1164
  .map((line) => line.trim())
@@ -754,9 +1185,730 @@ function parseStructuredUsage(stdout: string) {
754
1185
  return undefined;
755
1186
  }
756
1187
 
1188
+ function parseClaudeStreamOutput(stdout: string) {
1189
+ let summary = "";
1190
+ let tokenInput: number | undefined;
1191
+ let tokenOutput: number | undefined;
1192
+ let usdCost: number | undefined;
1193
+ let stopReason: string | undefined;
1194
+ let resultSubtype: string | undefined;
1195
+ let sessionId: string | undefined;
1196
+ const assistantTexts: string[] = [];
1197
+
1198
+ for (const rawLine of stdout.split(/\r?\n/)) {
1199
+ const line = rawLine.trim();
1200
+ if (!line) continue;
1201
+ let parsed: Record<string, unknown>;
1202
+ try {
1203
+ parsed = JSON.parse(line) as Record<string, unknown>;
1204
+ } catch {
1205
+ continue;
1206
+ }
1207
+ const type = typeof parsed.type === "string" ? parsed.type : "";
1208
+ if (!sessionId && typeof parsed.session_id === "string" && parsed.session_id.trim()) {
1209
+ sessionId = parsed.session_id.trim();
1210
+ }
1211
+ if (type === "assistant") {
1212
+ const message = parsed.message;
1213
+ if (message && typeof message === "object" && !Array.isArray(message)) {
1214
+ const content = (message as Record<string, unknown>).content;
1215
+ if (Array.isArray(content)) {
1216
+ for (const entry of content) {
1217
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
1218
+ const block = entry as Record<string, unknown>;
1219
+ if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
1220
+ assistantTexts.push(block.text.trim());
1221
+ }
1222
+ }
1223
+ }
1224
+ }
1225
+ continue;
1226
+ }
1227
+ if (type !== "result") continue;
1228
+ if (typeof parsed.result === "string" && parsed.result.trim()) {
1229
+ summary = parsed.result.trim();
1230
+ }
1231
+ const usage = parsed.usage;
1232
+ if (usage && typeof usage === "object" && !Array.isArray(usage)) {
1233
+ const usageObj = usage as Record<string, unknown>;
1234
+ const inputTokens = toNumber(usageObj.input_tokens);
1235
+ const cacheReadTokens = toNumber(usageObj.cache_read_input_tokens);
1236
+ const outputTokens = toNumber(usageObj.output_tokens);
1237
+ tokenInput =
1238
+ inputTokens !== undefined || cacheReadTokens !== undefined
1239
+ ? (inputTokens ?? 0) + (cacheReadTokens ?? 0)
1240
+ : undefined;
1241
+ tokenOutput = outputTokens;
1242
+ }
1243
+ usdCost = toNumber(parsed.total_cost_usd);
1244
+ if (typeof parsed.stop_reason === "string" && parsed.stop_reason.trim()) {
1245
+ stopReason = parsed.stop_reason.trim().toLowerCase();
1246
+ }
1247
+ if (typeof parsed.subtype === "string" && parsed.subtype.trim()) {
1248
+ resultSubtype = parsed.subtype.trim().toLowerCase();
1249
+ }
1250
+ }
1251
+
1252
+ const resolvedSummary = summary || assistantTexts.join("\n\n").trim();
1253
+ if (!resolvedSummary && tokenInput === undefined && tokenOutput === undefined && usdCost === undefined) {
1254
+ return undefined;
1255
+ }
1256
+ return {
1257
+ usage: {
1258
+ summary: resolvedSummary || undefined,
1259
+ tokenInput,
1260
+ tokenOutput,
1261
+ usdCost
1262
+ },
1263
+ stopReason,
1264
+ resultSubtype,
1265
+ sessionId
1266
+ };
1267
+ }
1268
+
1269
+ function parseCursorStreamOutput(stdout: string): CursorParsedStream | undefined {
1270
+ let sessionId: string | undefined;
1271
+ let errorMessage: string | undefined;
1272
+ let resultSubtype: string | undefined;
1273
+ let tokenInput = 0;
1274
+ let tokenOutput = 0;
1275
+ let usdCost = 0;
1276
+ let sawUsage = false;
1277
+ const assistantTexts: string[] = [];
1278
+
1279
+ for (const rawLine of stdout.split(/\r?\n/)) {
1280
+ const normalized = normalizeCursorStreamLine(rawLine).line;
1281
+ if (!normalized) {
1282
+ continue;
1283
+ }
1284
+ let parsed: Record<string, unknown>;
1285
+ try {
1286
+ parsed = JSON.parse(normalized) as Record<string, unknown>;
1287
+ } catch {
1288
+ continue;
1289
+ }
1290
+ const foundSessionId = readCursorSessionId(parsed);
1291
+ if (foundSessionId) {
1292
+ sessionId = foundSessionId;
1293
+ }
1294
+ const type = typeof parsed.type === "string" ? parsed.type.trim().toLowerCase() : "";
1295
+ if (type === "assistant") {
1296
+ assistantTexts.push(...collectCursorAssistantText(parsed.message));
1297
+ continue;
1298
+ }
1299
+ if (type === "result") {
1300
+ const usage = parsed.usage;
1301
+ if (usage && typeof usage === "object" && !Array.isArray(usage)) {
1302
+ const usageRecord = usage as Record<string, unknown>;
1303
+ tokenInput += toNumber(usageRecord.input_tokens) ?? toNumber(usageRecord.inputTokens) ?? 0;
1304
+ tokenInput +=
1305
+ toNumber(usageRecord.cached_input_tokens) ??
1306
+ toNumber(usageRecord.cachedInputTokens) ??
1307
+ toNumber(usageRecord.cache_read_input_tokens) ??
1308
+ 0;
1309
+ tokenOutput += toNumber(usageRecord.output_tokens) ?? toNumber(usageRecord.outputTokens) ?? 0;
1310
+ sawUsage = true;
1311
+ }
1312
+ usdCost += toNumber(parsed.total_cost_usd) ?? toNumber(parsed.cost_usd) ?? toNumber(parsed.cost) ?? 0;
1313
+ if (typeof parsed.subtype === "string" && parsed.subtype.trim()) {
1314
+ resultSubtype = parsed.subtype.trim().toLowerCase();
1315
+ }
1316
+ const resultText = firstNonEmptyString(parsed.result);
1317
+ if (resultText && assistantTexts.length === 0) {
1318
+ assistantTexts.push(resultText);
1319
+ }
1320
+ const isError = parsed.is_error === true || resultSubtype === "error";
1321
+ if (isError) {
1322
+ const message = asCursorErrorText(parsed.error ?? parsed.message ?? parsed.result);
1323
+ if (message) {
1324
+ errorMessage = message;
1325
+ }
1326
+ }
1327
+ continue;
1328
+ }
1329
+ if (type === "error") {
1330
+ const message = asCursorErrorText(parsed.message ?? parsed.error ?? parsed.detail);
1331
+ if (message) {
1332
+ errorMessage = message;
1333
+ }
1334
+ continue;
1335
+ }
1336
+ if (type === "system") {
1337
+ const subtype = typeof parsed.subtype === "string" ? parsed.subtype.trim().toLowerCase() : "";
1338
+ if (subtype === "error") {
1339
+ const message = asCursorErrorText(parsed.message ?? parsed.error ?? parsed.detail);
1340
+ if (message) {
1341
+ errorMessage = message;
1342
+ }
1343
+ }
1344
+ continue;
1345
+ }
1346
+ if (type === "text") {
1347
+ const part = parsed.part;
1348
+ if (part && typeof part === "object" && !Array.isArray(part)) {
1349
+ const text = firstNonEmptyString((part as Record<string, unknown>).text);
1350
+ if (text) {
1351
+ assistantTexts.push(text);
1352
+ }
1353
+ }
1354
+ continue;
1355
+ }
1356
+ if (type === "step_finish") {
1357
+ const part = parsed.part;
1358
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
1359
+ continue;
1360
+ }
1361
+ const tokens = (part as Record<string, unknown>).tokens;
1362
+ if (tokens && typeof tokens === "object" && !Array.isArray(tokens)) {
1363
+ const tokenRecord = tokens as Record<string, unknown>;
1364
+ tokenInput += toNumber(tokenRecord.input) ?? 0;
1365
+ tokenOutput += toNumber(tokenRecord.output) ?? 0;
1366
+ const cache = tokenRecord.cache;
1367
+ if (cache && typeof cache === "object" && !Array.isArray(cache)) {
1368
+ tokenInput += toNumber((cache as Record<string, unknown>).read) ?? 0;
1369
+ }
1370
+ sawUsage = true;
1371
+ }
1372
+ usdCost += toNumber((part as Record<string, unknown>).cost) ?? 0;
1373
+ }
1374
+ }
1375
+
1376
+ const summary = assistantTexts.join("\n\n").trim() || errorMessage;
1377
+ if (!summary && !sawUsage && usdCost <= 0) {
1378
+ return undefined;
1379
+ }
1380
+ return {
1381
+ usage: {
1382
+ summary: summary || undefined,
1383
+ tokenInput: sawUsage ? tokenInput : undefined,
1384
+ tokenOutput: sawUsage ? tokenOutput : undefined,
1385
+ usdCost: usdCost > 0 ? usdCost : undefined
1386
+ },
1387
+ sessionId,
1388
+ errorMessage,
1389
+ resultSubtype
1390
+ };
1391
+ }
1392
+
1393
+ function parseClaudeTranscript(stdout: string, stderr: string): RuntimeTranscriptEvent[] | undefined {
1394
+ const events: RuntimeTranscriptEvent[] = [];
1395
+ for (const rawLine of stdout.split(/\r?\n/)) {
1396
+ const line = rawLine.trim();
1397
+ if (!line.startsWith("{") || !line.endsWith("}")) {
1398
+ continue;
1399
+ }
1400
+ let parsed: Record<string, unknown>;
1401
+ try {
1402
+ parsed = JSON.parse(line) as Record<string, unknown>;
1403
+ } catch {
1404
+ continue;
1405
+ }
1406
+ const type = typeof parsed.type === "string" ? parsed.type : "";
1407
+ if (type === "system") {
1408
+ const model = typeof parsed.model === "string" ? parsed.model : "";
1409
+ const subtype = typeof parsed.subtype === "string" ? parsed.subtype : undefined;
1410
+ events.push({
1411
+ kind: "system",
1412
+ label: subtype,
1413
+ text: model ? `model:${model}` : "session init"
1414
+ });
1415
+ continue;
1416
+ }
1417
+ if (type === "assistant" || type === "user") {
1418
+ const message = parsed.message;
1419
+ const content =
1420
+ message && typeof message === "object" && !Array.isArray(message)
1421
+ ? ((message as Record<string, unknown>).content as unknown[])
1422
+ : undefined;
1423
+ if (!Array.isArray(content)) {
1424
+ continue;
1425
+ }
1426
+ for (const entry of content) {
1427
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
1428
+ continue;
1429
+ }
1430
+ const block = entry as Record<string, unknown>;
1431
+ const blockType = typeof block.type === "string" ? block.type : "";
1432
+ if (blockType === "thinking" && typeof block.thinking === "string") {
1433
+ events.push({ kind: "thinking", text: clipText(block.thinking, 300) });
1434
+ continue;
1435
+ }
1436
+ if (blockType === "text" && typeof block.text === "string") {
1437
+ events.push({ kind: "assistant", text: clipText(block.text, 300) });
1438
+ continue;
1439
+ }
1440
+ if (blockType === "tool_use") {
1441
+ const label = typeof block.name === "string" ? block.name : "tool";
1442
+ const payload = block.input ? safeJson(block.input) : undefined;
1443
+ events.push({
1444
+ kind: "tool_call",
1445
+ label,
1446
+ payload: payload ? clipText(payload, 520) : undefined
1447
+ });
1448
+ continue;
1449
+ }
1450
+ if (blockType === "tool_result") {
1451
+ const payload = typeof block.content === "string" ? block.content : safeJson(block.content);
1452
+ events.push({
1453
+ kind: "tool_result",
1454
+ text: payload ? clipText(payload, 320) : "tool result"
1455
+ });
1456
+ }
1457
+ }
1458
+ continue;
1459
+ }
1460
+ if (type === "result") {
1461
+ const stopReason = typeof parsed.stop_reason === "string" ? parsed.stop_reason : undefined;
1462
+ const result = typeof parsed.result === "string" ? parsed.result : "result event";
1463
+ events.push({
1464
+ kind: "result",
1465
+ label: stopReason,
1466
+ text: clipText(result, 320)
1467
+ });
1468
+ }
1469
+ }
1470
+ const stderrEvents = parseStderrTranscript(stderr);
1471
+ if (stderrEvents) {
1472
+ events.push(...stderrEvents.slice(0, 10));
1473
+ }
1474
+ if (events.length === 0) {
1475
+ return undefined;
1476
+ }
1477
+ return events.slice(0, 120);
1478
+ }
1479
+
1480
+ function parseRuntimeTranscript(
1481
+ provider: LocalProvider | undefined,
1482
+ stdout: string,
1483
+ stderr: string
1484
+ ): RuntimeTranscriptEvent[] | undefined {
1485
+ const claudeEvents = provider === "claude_code" ? parseClaudeTranscript(stdout, stderr) : undefined;
1486
+ const cursorEvents = provider === "cursor" ? parseCursorTranscript(stdout, stderr) : undefined;
1487
+ const genericEvents = parseGenericTranscript(stdout, stderr);
1488
+ const providerEvents = claudeEvents ?? cursorEvents;
1489
+ if (providerEvents?.length && genericEvents?.length) {
1490
+ return [...providerEvents, ...genericEvents].slice(0, 140);
1491
+ }
1492
+ return providerEvents ?? genericEvents;
1493
+ }
1494
+
1495
+ function parseCursorTranscript(stdout: string, stderr: string): RuntimeTranscriptEvent[] | undefined {
1496
+ const events: RuntimeTranscriptEvent[] = [];
1497
+ for (const rawLine of stdout.split(/\r?\n/)) {
1498
+ const normalized = normalizeCursorStreamLine(rawLine).line;
1499
+ if (!normalized.startsWith("{") || !normalized.endsWith("}")) {
1500
+ continue;
1501
+ }
1502
+ let parsed: Record<string, unknown>;
1503
+ try {
1504
+ parsed = JSON.parse(normalized) as Record<string, unknown>;
1505
+ } catch {
1506
+ continue;
1507
+ }
1508
+ const type = typeof parsed.type === "string" ? parsed.type.trim().toLowerCase() : "";
1509
+ if (type === "system") {
1510
+ const model = firstNonEmptyString(parsed.model);
1511
+ const sessionId = readCursorSessionId(parsed);
1512
+ const bits = [model ? `model:${model}` : "", sessionId ? `session:${sessionId}` : ""].filter(Boolean);
1513
+ events.push({
1514
+ kind: "system",
1515
+ label: firstNonEmptyString(parsed.subtype),
1516
+ text: bits.join(" ") || "session init"
1517
+ });
1518
+ continue;
1519
+ }
1520
+ if (type === "assistant" || type === "user") {
1521
+ const kind = type === "user" ? "system" : "assistant";
1522
+ const content =
1523
+ parsed.message && typeof parsed.message === "object" && !Array.isArray(parsed.message)
1524
+ ? (((parsed.message as Record<string, unknown>).content as unknown[]) ?? [])
1525
+ : [];
1526
+ for (const entry of content) {
1527
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
1528
+ continue;
1529
+ }
1530
+ const block = entry as Record<string, unknown>;
1531
+ const blockType = typeof block.type === "string" ? block.type.trim().toLowerCase() : "";
1532
+ if ((blockType === "thinking" || blockType === "reasoning") && typeof block.text === "string") {
1533
+ events.push({ kind: "thinking", text: clipText(block.text, 300) });
1534
+ continue;
1535
+ }
1536
+ if (blockType === "text" || blockType === "output_text") {
1537
+ const text = firstNonEmptyString(block.text);
1538
+ if (text) {
1539
+ events.push({ kind, text: clipText(text, 300) });
1540
+ }
1541
+ continue;
1542
+ }
1543
+ if (blockType === "tool_call" || blockType === "tool_use") {
1544
+ const label = firstNonEmptyString(block.name) ?? firstNonEmptyString(block.tool_name) ?? "tool";
1545
+ const payload = safeJson(block.input ?? block.args ?? block.tool_call);
1546
+ events.push({
1547
+ kind: "tool_call",
1548
+ label,
1549
+ ...(payload ? { payload: clipText(payload, 520) } : {})
1550
+ });
1551
+ continue;
1552
+ }
1553
+ if (blockType === "tool_result") {
1554
+ const payload = safeJson(block.output ?? block.content ?? block.result);
1555
+ events.push({
1556
+ kind: "tool_result",
1557
+ label: firstNonEmptyString(block.tool_use_id) ?? undefined,
1558
+ text: clipText(payload ?? "tool result", 320)
1559
+ });
1560
+ }
1561
+ }
1562
+ continue;
1563
+ }
1564
+ if (type === "thinking") {
1565
+ const thinkingText = firstNonEmptyString(parsed.text, parsed.message);
1566
+ if (thinkingText) {
1567
+ events.push({ kind: "thinking", text: clipText(thinkingText, 300) });
1568
+ }
1569
+ continue;
1570
+ }
1571
+ if (type === "tool_call") {
1572
+ const subtype = firstNonEmptyString(parsed.subtype);
1573
+ const toolCall = parsed.tool_call;
1574
+ if (toolCall && typeof toolCall === "object" && !Array.isArray(toolCall)) {
1575
+ const [toolName, toolValue] = Object.entries(toolCall as Record<string, unknown>)[0] ?? [];
1576
+ if (subtype === "completed") {
1577
+ events.push({
1578
+ kind: "tool_result",
1579
+ label: firstNonEmptyString(parsed.call_id) ?? toolName ?? undefined,
1580
+ text: clipText(safeJson(toolValue) ?? "tool result", 320)
1581
+ });
1582
+ } else {
1583
+ const payload =
1584
+ toolValue && typeof toolValue === "object" && !Array.isArray(toolValue)
1585
+ ? safeJson((toolValue as Record<string, unknown>).args ?? toolValue)
1586
+ : safeJson(toolValue);
1587
+ events.push({
1588
+ kind: "tool_call",
1589
+ label: toolName ?? firstNonEmptyString(parsed.call_id) ?? "tool",
1590
+ ...(payload ? { payload: clipText(payload, 520) } : {})
1591
+ });
1592
+ }
1593
+ }
1594
+ continue;
1595
+ }
1596
+ if (type === "result" || type === "error") {
1597
+ const label = firstNonEmptyString(parsed.subtype);
1598
+ const text =
1599
+ firstNonEmptyString(parsed.result, parsed.message, parsed.error, parsed.detail) ?? `${type} event`;
1600
+ events.push({
1601
+ kind: type === "error" ? "stderr" : "result",
1602
+ ...(label ? { label } : {}),
1603
+ text: clipText(text, 320)
1604
+ });
1605
+ }
1606
+ }
1607
+ const stderrEvents = parseStderrTranscript(stderr);
1608
+ if (stderrEvents) {
1609
+ events.push(...stderrEvents.slice(0, 10));
1610
+ }
1611
+ if (events.length === 0) {
1612
+ return undefined;
1613
+ }
1614
+ return events.slice(0, 120);
1615
+ }
1616
+
1617
+ function parseGenericTranscript(stdout: string, stderr: string): RuntimeTranscriptEvent[] | undefined {
1618
+ const events: RuntimeTranscriptEvent[] = [];
1619
+ let lastToolEventIndex = -1;
1620
+ for (const rawLine of stdout.split(/\r?\n/)) {
1621
+ const line = rawLine.trim();
1622
+ if (!line) {
1623
+ continue;
1624
+ }
1625
+ const fromJson = parseGenericTranscriptJsonLine(line);
1626
+ if (fromJson) {
1627
+ events.push(fromJson);
1628
+ lastToolEventIndex =
1629
+ fromJson.kind === "tool_call" || fromJson.kind === "tool_result" ? events.length - 1 : lastToolEventIndex;
1630
+ continue;
1631
+ }
1632
+ const fromTagged = parseTaggedTranscriptLine(line);
1633
+ if (fromTagged) {
1634
+ events.push(fromTagged);
1635
+ lastToolEventIndex =
1636
+ fromTagged.kind === "tool_call" || fromTagged.kind === "tool_result" ? events.length - 1 : lastToolEventIndex;
1637
+ continue;
1638
+ }
1639
+ if (lastToolEventIndex >= 0 && looksLikeToolPayloadLine(line)) {
1640
+ const current = events[lastToolEventIndex];
1641
+ if (!current) {
1642
+ continue;
1643
+ }
1644
+ const nextPayload = [current.payload, line].filter(Boolean).join("\n");
1645
+ events[lastToolEventIndex] = {
1646
+ ...current,
1647
+ payload: clipText(nextPayload, 520)
1648
+ };
1649
+ }
1650
+ }
1651
+ const stderrEvents = parseStderrTranscript(stderr);
1652
+ if (stderrEvents) {
1653
+ events.push(...stderrEvents.slice(0, 10));
1654
+ }
1655
+ if (events.length === 0) {
1656
+ return undefined;
1657
+ }
1658
+ return events.slice(0, 120);
1659
+ }
1660
+
1661
+ function parseGenericTranscriptJsonLine(line: string): RuntimeTranscriptEvent | undefined {
1662
+ if (!line.startsWith("{") || !line.endsWith("}")) {
1663
+ return undefined;
1664
+ }
1665
+ let parsed: Record<string, unknown>;
1666
+ try {
1667
+ parsed = JSON.parse(line) as Record<string, unknown>;
1668
+ } catch {
1669
+ return undefined;
1670
+ }
1671
+ const kind = normalizeTranscriptKind(parsed.type ?? parsed.kind ?? parsed.event);
1672
+ if (!kind) {
1673
+ return undefined;
1674
+ }
1675
+ const label = pickString(parsed.subtype, parsed.name, parsed.tool, parsed.tool_name, parsed.status);
1676
+ const text =
1677
+ pickString(
1678
+ parsed.message,
1679
+ parsed.text,
1680
+ parsed.result,
1681
+ parsed.summary,
1682
+ parsed.content,
1683
+ parsed.detail,
1684
+ parsed.command
1685
+ ) ?? `${kind} event`;
1686
+ const payload = safeJson(parsed.input ?? parsed.arguments ?? parsed.params ?? parsed.output ?? parsed.data);
1687
+ return {
1688
+ kind,
1689
+ ...(label ? { label: clipText(label, 100) } : {}),
1690
+ text: clipText(text, 300),
1691
+ ...(payload ? { payload: clipText(payload, 520) } : {})
1692
+ };
1693
+ }
1694
+
1695
+ function parseTaggedTranscriptLine(line: string): RuntimeTranscriptEvent | undefined {
1696
+ const timestampPrefix = /^\d{2}:\d{2}:\d{2}\s+/;
1697
+ const withoutTimestamp = timestampPrefix.test(line) ? line.replace(timestampPrefix, "") : line;
1698
+ const match = /^(system|assistant|thinking|tool_call|tool_result|result|stderr)\s*(.*)$/i.exec(
1699
+ withoutTimestamp
1700
+ );
1701
+ if (!match) {
1702
+ return undefined;
1703
+ }
1704
+ const kind = normalizeTranscriptKind(match[1]);
1705
+ if (!kind) {
1706
+ return undefined;
1707
+ }
1708
+ const rest = match[2]?.trim();
1709
+ return {
1710
+ kind,
1711
+ text: clipText(rest || `${kind} event`, 300),
1712
+ ...(rest && (kind === "tool_call" || kind === "tool_result") ? { label: clipText(rest, 120) } : {})
1713
+ };
1714
+ }
1715
+
1716
+ function normalizeTranscriptKind(value: unknown): RuntimeTranscriptEvent["kind"] | undefined {
1717
+ if (typeof value !== "string") {
1718
+ return undefined;
1719
+ }
1720
+ const normalized = value.trim().toLowerCase();
1721
+ if (
1722
+ normalized === "system" ||
1723
+ normalized === "assistant" ||
1724
+ normalized === "thinking" ||
1725
+ normalized === "tool_call" ||
1726
+ normalized === "tool_result" ||
1727
+ normalized === "result" ||
1728
+ normalized === "stderr"
1729
+ ) {
1730
+ return normalized;
1731
+ }
1732
+ return undefined;
1733
+ }
1734
+
1735
+ function pickString(...values: unknown[]) {
1736
+ for (const value of values) {
1737
+ if (typeof value === "string" && value.trim().length > 0) {
1738
+ return value.trim();
1739
+ }
1740
+ }
1741
+ return undefined;
1742
+ }
1743
+
1744
+ function looksLikeToolPayloadLine(line: string) {
1745
+ return (
1746
+ line.startsWith("{") ||
1747
+ line.startsWith("}") ||
1748
+ line.startsWith("\"") ||
1749
+ /^[a-zA-Z_][a-zA-Z0-9_]*\s*:/.test(line)
1750
+ );
1751
+ }
1752
+
1753
+ function parseStderrTranscript(stderr: string): RuntimeTranscriptEvent[] | undefined {
1754
+ const lines = stderr
1755
+ .split(/\r?\n/)
1756
+ .map((line) => line.trim())
1757
+ .filter(Boolean)
1758
+ .slice(0, 40);
1759
+ if (lines.length === 0) {
1760
+ return undefined;
1761
+ }
1762
+ return lines.map((line) => ({
1763
+ kind: "stderr",
1764
+ text: clipText(line, 300)
1765
+ }));
1766
+ }
1767
+
1768
+ function safeJson(value: unknown) {
1769
+ try {
1770
+ return JSON.stringify(value);
1771
+ } catch {
1772
+ return undefined;
1773
+ }
1774
+ }
1775
+
1776
+ function normalizeCursorStreamLine(rawLine: string) {
1777
+ const trimmed = rawLine.trim();
1778
+ if (trimmed.startsWith("stdout{") || trimmed.startsWith("stderr{")) {
1779
+ return { line: trimmed.slice(6), stream: trimmed.slice(0, 6) as "stdout" | "stderr" };
1780
+ }
1781
+ return { line: trimmed, stream: undefined };
1782
+ }
1783
+
1784
+ function readCursorSessionId(event: Record<string, unknown>) {
1785
+ return firstNonEmptyString(event.session_id, event.sessionId, event.sessionID);
1786
+ }
1787
+
1788
+ function collectCursorAssistantText(message: unknown) {
1789
+ if (typeof message === "string") {
1790
+ return message.trim() ? [message.trim()] : [];
1791
+ }
1792
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
1793
+ return [];
1794
+ }
1795
+ const record = message as Record<string, unknown>;
1796
+ const lines: string[] = [];
1797
+ const directText = firstNonEmptyString(record.text);
1798
+ if (directText) {
1799
+ lines.push(directText);
1800
+ }
1801
+ const content = Array.isArray(record.content) ? record.content : [];
1802
+ for (const entry of content) {
1803
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
1804
+ continue;
1805
+ }
1806
+ const block = entry as Record<string, unknown>;
1807
+ const type = typeof block.type === "string" ? block.type.trim().toLowerCase() : "";
1808
+ if (type !== "output_text" && type !== "text") {
1809
+ continue;
1810
+ }
1811
+ const text = firstNonEmptyString(block.text);
1812
+ if (text) {
1813
+ lines.push(text);
1814
+ }
1815
+ }
1816
+ return lines;
1817
+ }
1818
+
1819
+ function asCursorErrorText(value: unknown) {
1820
+ if (typeof value === "string" && value.trim()) {
1821
+ return value.trim();
1822
+ }
1823
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1824
+ return "";
1825
+ }
1826
+ const record = value as Record<string, unknown>;
1827
+ const direct = firstNonEmptyString(record.message, record.error, record.code, record.detail);
1828
+ if (direct) {
1829
+ return direct;
1830
+ }
1831
+ return safeJson(record) ?? "";
1832
+ }
1833
+
1834
+ function firstNonEmptyString(...values: unknown[]) {
1835
+ for (const value of values) {
1836
+ if (typeof value === "string" && value.trim()) {
1837
+ return value.trim();
1838
+ }
1839
+ }
1840
+ return undefined;
1841
+ }
1842
+
1843
+ function clipText(text: string, max = 220) {
1844
+ const normalized = text.replace(/\s+/g, " ").trim();
1845
+ if (normalized.length <= max) {
1846
+ return normalized;
1847
+ }
1848
+ return `${normalized.slice(0, max)}...`;
1849
+ }
1850
+
1851
+ function extractJsonObjectBlocks(text: string) {
1852
+ const blocks: string[] = [];
1853
+ let depth = 0;
1854
+ let startIndex = -1;
1855
+ let inString = false;
1856
+ let escaped = false;
1857
+ for (let index = 0; index < text.length; index += 1) {
1858
+ const char = text[index];
1859
+ if (inString) {
1860
+ if (escaped) {
1861
+ escaped = false;
1862
+ } else if (char === "\\") {
1863
+ escaped = true;
1864
+ } else if (char === "\"") {
1865
+ inString = false;
1866
+ }
1867
+ continue;
1868
+ }
1869
+ if (char === "\"") {
1870
+ inString = true;
1871
+ continue;
1872
+ }
1873
+ if (char === "{") {
1874
+ if (depth === 0) {
1875
+ startIndex = index;
1876
+ }
1877
+ depth += 1;
1878
+ continue;
1879
+ }
1880
+ if (char === "}") {
1881
+ if (depth === 0) {
1882
+ continue;
1883
+ }
1884
+ depth -= 1;
1885
+ if (depth === 0 && startIndex >= 0) {
1886
+ blocks.push(text.slice(startIndex, index + 1));
1887
+ startIndex = -1;
1888
+ }
1889
+ }
1890
+ }
1891
+ return blocks;
1892
+ }
1893
+
757
1894
  function tryParseUsage(candidate: string) {
758
1895
  try {
759
1896
  const parsed = JSON.parse(candidate) as Record<string, unknown>;
1897
+ const direct = toUsageRecord(parsed);
1898
+ if (direct) {
1899
+ return direct;
1900
+ }
1901
+ const nested = findNestedUsage(parsed);
1902
+ if (nested) {
1903
+ return nested;
1904
+ }
1905
+ return undefined;
1906
+ } catch {
1907
+ return undefined;
1908
+ }
1909
+ }
1910
+
1911
+ function toUsageRecord(parsed: Record<string, unknown>) {
760
1912
  const tokenInput = toNumber(parsed.tokenInput);
761
1913
  const tokenOutput = toNumber(parsed.tokenOutput);
762
1914
  const usdCost = toNumber(parsed.usdCost);
@@ -773,9 +1925,45 @@ function tryParseUsage(candidate: string) {
773
1925
  return undefined;
774
1926
  }
775
1927
  return { tokenInput, tokenOutput, usdCost, summary };
776
- } catch {
777
- return undefined;
1928
+ }
1929
+
1930
+ function findNestedUsage(parsed: Record<string, unknown>) {
1931
+ const queue: unknown[] = Object.values(parsed);
1932
+ while (queue.length > 0) {
1933
+ const current = queue.shift();
1934
+ if (!current) {
1935
+ continue;
1936
+ }
1937
+ if (typeof current === "string") {
1938
+ const trimmed = current.trim();
1939
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
1940
+ try {
1941
+ const parsedString = JSON.parse(trimmed) as Record<string, unknown>;
1942
+ const usage = toUsageRecord(parsedString);
1943
+ if (usage) {
1944
+ return usage;
1945
+ }
1946
+ queue.push(...Object.values(parsedString));
1947
+ } catch {
1948
+ // ignore malformed nested JSON
1949
+ }
1950
+ }
1951
+ continue;
1952
+ }
1953
+ if (Array.isArray(current)) {
1954
+ queue.push(...current);
1955
+ continue;
1956
+ }
1957
+ if (typeof current === "object") {
1958
+ const objectValue = current as Record<string, unknown>;
1959
+ const usage = toUsageRecord(objectValue);
1960
+ if (usage) {
1961
+ return usage;
1962
+ }
1963
+ queue.push(...Object.values(objectValue));
1964
+ }
778
1965
  }
1966
+ return undefined;
779
1967
  }
780
1968
 
781
1969
  function isPromptTemplateUsage(
@@ -812,3 +2000,32 @@ function toNumber(value: unknown) {
812
2000
  }
813
2001
  return undefined;
814
2002
  }
2003
+
2004
+ function tailLine(value: string) {
2005
+ const lines = value
2006
+ .split(/\r?\n/)
2007
+ .map((line) => line.trim())
2008
+ .filter(Boolean);
2009
+ return lines.length > 0 ? lines[lines.length - 1] : undefined;
2010
+ }
2011
+
2012
+ function classifyStructuredOutputLikelyCause(
2013
+ stdout: string,
2014
+ stderr: string,
2015
+ stdoutUsage: { summary?: string } | undefined,
2016
+ stderrUsage: { summary?: string } | undefined
2017
+ ) {
2018
+ const hasStdout = stdout.trim().length > 0;
2019
+ const hasStderr = stderr.trim().length > 0;
2020
+ if (!hasStdout && !hasStderr) {
2021
+ return "no_output_from_runtime" as const;
2022
+ }
2023
+ if (!stdoutUsage && stderrUsage) {
2024
+ return "json_on_stderr_only" as const;
2025
+ }
2026
+ const jsonLike = /[\{\}\[\]\"]/m.test(stdout) || /[\{\}\[\]\"]/m.test(stderr);
2027
+ if (jsonLike) {
2028
+ return "schema_or_shape_mismatch" as const;
2029
+ }
2030
+ return "json_missing" as const;
2031
+ }