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/adapters.ts CHANGED
@@ -1,5 +1,18 @@
1
- import type { AdapterExecutionResult, AgentAdapter, HeartbeatContext } from "./types";
2
- import { executeAgentRuntime, executePromptRuntime } from "./runtime";
1
+ import type {
2
+ AdapterEnvironmentCheck,
3
+ AdapterEnvironmentResult,
4
+ AdapterExecutionResult,
5
+ AdapterMetadata,
6
+ AdapterModelOption,
7
+ AgentAdapter,
8
+ AgentProviderType,
9
+ AgentRuntimeConfig,
10
+ HeartbeatContext
11
+ } from "./types";
12
+ import { ExecutionOutcomeSchema, type ExecutionOutcome } from "bopodev-contracts";
13
+ import { checkRuntimeCommandHealth, executeAgentRuntime, executePromptRuntime } from "./runtime";
14
+ import { homedir } from "node:os";
15
+ import { join, resolve } from "node:path";
3
16
 
4
17
  function summarizeWork(context: HeartbeatContext) {
5
18
  if (context.workItems.length === 0) {
@@ -8,6 +21,14 @@ function summarizeWork(context: HeartbeatContext) {
8
21
  return `Processed ${context.workItems.length} assigned issue(s).`;
9
22
  }
10
23
 
24
+ function issueIdsTouched(context: HeartbeatContext) {
25
+ return context.workItems.map((item) => item.issueId);
26
+ }
27
+
28
+ function toOutcome(outcome: ExecutionOutcome): ExecutionOutcome {
29
+ return ExecutionOutcomeSchema.parse(outcome);
30
+ }
31
+
11
32
  export class ClaudeCodeAdapter implements AgentAdapter {
12
33
  providerType = "claude_code" as const;
13
34
 
@@ -36,6 +57,28 @@ export class CodexAdapter implements AgentAdapter {
36
57
  }
37
58
  }
38
59
 
60
+ export class CursorAdapter implements AgentAdapter {
61
+ providerType = "cursor" as const;
62
+
63
+ async execute(context: HeartbeatContext): Promise<AdapterExecutionResult> {
64
+ if (context.workItems.length === 0) {
65
+ return createSkippedResult("Cursor", "cursor", context);
66
+ }
67
+ return runCursorWork(context);
68
+ }
69
+ }
70
+
71
+ export class OpenCodeAdapter implements AgentAdapter {
72
+ providerType = "opencode" as const;
73
+
74
+ async execute(context: HeartbeatContext): Promise<AdapterExecutionResult> {
75
+ if (context.workItems.length === 0) {
76
+ return createSkippedResult("OpenCode", "opencode", context);
77
+ }
78
+ return runOpenCodeWork(context);
79
+ }
80
+ }
81
+
39
82
  export class GenericHeartbeatAdapter implements AgentAdapter {
40
83
  constructor(public providerType: "http" | "shell") {}
41
84
 
@@ -50,6 +93,14 @@ export class GenericHeartbeatAdapter implements AgentAdapter {
50
93
  tokenInput: 0,
51
94
  tokenOutput: 0,
52
95
  usdCost: 0,
96
+ outcome: toOutcome({
97
+ kind: "failed",
98
+ issueIdsTouched: issueIdsTouched(context),
99
+ actions: [{ type: "runtime.launch", status: "error", detail: "Missing runtime command." }],
100
+ blockers: [{ code: "runtime_command_missing", message: "Runtime command is required.", retryable: false }],
101
+ artifacts: [],
102
+ nextSuggestedState: "blocked"
103
+ }),
53
104
  nextState: context.state
54
105
  };
55
106
  }
@@ -57,22 +108,73 @@ export class GenericHeartbeatAdapter implements AgentAdapter {
57
108
  const prompt = createPrompt(context);
58
109
  const runtime = await executePromptRuntime(context.runtime.command, prompt, context.runtime);
59
110
  if (runtime.ok) {
111
+ if (!runtime.parsedUsage) {
112
+ const detail = buildMissingStructuredOutputDetail(this.providerType, runtime);
113
+ return {
114
+ status: "failed",
115
+ summary: `${this.providerType} runtime failed: ${detail}`,
116
+ tokenInput: 0,
117
+ tokenOutput: 0,
118
+ usdCost: 0,
119
+ outcome: toOutcome({
120
+ kind: "failed",
121
+ issueIdsTouched: issueIdsTouched(context),
122
+ actions: [{ type: "runtime.execute", status: "error", detail }],
123
+ blockers: [{ code: "missing_structured_output", message: detail, retryable: true }],
124
+ artifacts: [],
125
+ nextSuggestedState: "blocked"
126
+ }),
127
+ trace: {
128
+ command: runtime.commandUsed ?? context.runtime.command,
129
+ args: runtime.argsUsed,
130
+ cwd: context.runtime?.cwd,
131
+ exitCode: runtime.code,
132
+ elapsedMs: runtime.elapsedMs,
133
+ timedOut: runtime.timedOut,
134
+ failureType: "missing_structured_output",
135
+ timeoutSource: runtime.timedOut ? "runtime" : null,
136
+ attemptCount: runtime.attemptCount,
137
+ attempts: runtime.attempts,
138
+ structuredOutputSource: runtime.structuredOutputSource,
139
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
140
+ stdoutPreview: toPreview(runtime.stdout),
141
+ stderrPreview: toPreview(runtime.stderr),
142
+ transcript: runtime.transcript
143
+ },
144
+ nextState: context.state
145
+ };
146
+ }
60
147
  return {
61
148
  status: "ok",
62
149
  summary: runtime.parsedUsage?.summary ?? `${this.providerType} runtime finished in ${runtime.elapsedMs}ms.`,
63
150
  tokenInput: runtime.parsedUsage?.tokenInput ?? 0,
64
151
  tokenOutput: runtime.parsedUsage?.tokenOutput ?? 0,
65
152
  usdCost: runtime.parsedUsage?.usdCost ?? 0,
153
+ outcome: toOutcome({
154
+ kind: "completed",
155
+ issueIdsTouched: issueIdsTouched(context),
156
+ actions: [{ type: "runtime.execute", status: "ok", detail: `${this.providerType} runtime completed.` }],
157
+ blockers: [],
158
+ artifacts: [],
159
+ nextSuggestedState: "in_review"
160
+ }),
66
161
  trace: {
67
- command: context.runtime.command,
162
+ command: runtime.commandUsed ?? context.runtime.command,
163
+ args: runtime.argsUsed,
164
+ cwd: context.runtime?.cwd,
68
165
  exitCode: runtime.code,
69
166
  elapsedMs: runtime.elapsedMs,
70
167
  timedOut: runtime.timedOut,
71
168
  failureType: runtime.failureType,
169
+ timeoutSource: runtime.timedOut ? "runtime" : null,
170
+ usageSource: "structured",
72
171
  attemptCount: runtime.attemptCount,
73
172
  attempts: runtime.attempts,
173
+ structuredOutputSource: runtime.structuredOutputSource,
174
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
74
175
  stdoutPreview: toPreview(runtime.stdout),
75
- stderrPreview: toPreview(runtime.stderr)
176
+ stderrPreview: toPreview(runtime.stderr),
177
+ transcript: runtime.transcript
76
178
  },
77
179
  nextState: withProviderMetadata(context, this.providerType, runtime.elapsedMs, runtime.code)
78
180
  };
@@ -82,29 +184,255 @@ export class GenericHeartbeatAdapter implements AgentAdapter {
82
184
  inputRate: 0.000001,
83
185
  outputRate: 0.000004
84
186
  });
187
+ const failureDetail = resolveRuntimeFailureDetail(runtime);
85
188
  return {
86
189
  status: "failed",
87
- summary: runtime.parsedUsage?.summary ?? `${this.providerType} runtime failed: ${runtime.stderr || "unknown error"}`,
190
+ summary: runtime.parsedUsage?.summary ?? `${this.providerType} runtime failed: ${failureDetail}`,
88
191
  tokenInput: failedUsage.tokenInput,
89
192
  tokenOutput: failedUsage.tokenOutput,
90
193
  usdCost: failedUsage.usdCost,
194
+ outcome: toOutcome({
195
+ kind: "failed",
196
+ issueIdsTouched: issueIdsTouched(context),
197
+ actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
198
+ blockers: [{ code: runtime.failureType ?? "runtime_failed", message: failureDetail, retryable: true }],
199
+ artifacts: [],
200
+ nextSuggestedState: "blocked"
201
+ }),
91
202
  trace: {
92
- command: context.runtime.command,
203
+ command: runtime.commandUsed ?? context.runtime.command,
204
+ args: runtime.argsUsed,
205
+ cwd: context.runtime?.cwd,
93
206
  exitCode: runtime.code,
94
207
  elapsedMs: runtime.elapsedMs,
95
208
  timedOut: runtime.timedOut,
96
209
  failureType: runtime.failureType,
210
+ timeoutSource: runtime.timedOut ? "runtime" : null,
97
211
  attemptCount: runtime.attemptCount,
98
212
  attempts: runtime.attempts,
99
213
  usageSource: failedUsage.source,
214
+ structuredOutputSource: runtime.structuredOutputSource,
215
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
100
216
  stdoutPreview: toPreview(runtime.stdout),
101
- stderrPreview: toPreview(runtime.stderr)
217
+ stderrPreview: toPreview(runtime.stderr),
218
+ transcript: runtime.transcript
102
219
  },
103
220
  nextState: context.state
104
221
  };
105
222
  }
106
223
  }
107
224
 
225
+ type ProviderSessionUpdate = {
226
+ currentSessionId?: string | null;
227
+ resumedSessionId?: string | null;
228
+ resumeAttempted?: boolean;
229
+ resumeSkippedReason?: string | null;
230
+ clearedStaleSession?: boolean;
231
+ cwd?: string | null;
232
+ };
233
+
234
+ const staticMetadata: AdapterMetadata[] = [
235
+ {
236
+ providerType: "claude_code",
237
+ label: "Claude Code",
238
+ supportsModelSelection: true,
239
+ supportsEnvironmentTest: true,
240
+ supportsWebSearch: false,
241
+ supportsThinkingEffort: true,
242
+ requiresRuntimeCwd: true
243
+ },
244
+ {
245
+ providerType: "codex",
246
+ label: "Codex",
247
+ supportsModelSelection: true,
248
+ supportsEnvironmentTest: true,
249
+ supportsWebSearch: true,
250
+ supportsThinkingEffort: true,
251
+ requiresRuntimeCwd: true
252
+ },
253
+ {
254
+ providerType: "cursor",
255
+ label: "Cursor",
256
+ supportsModelSelection: true,
257
+ supportsEnvironmentTest: true,
258
+ supportsWebSearch: false,
259
+ supportsThinkingEffort: false,
260
+ requiresRuntimeCwd: true
261
+ },
262
+ {
263
+ providerType: "opencode",
264
+ label: "OpenCode",
265
+ supportsModelSelection: true,
266
+ supportsEnvironmentTest: true,
267
+ supportsWebSearch: false,
268
+ supportsThinkingEffort: false,
269
+ requiresRuntimeCwd: true
270
+ },
271
+ {
272
+ providerType: "http",
273
+ label: "HTTP",
274
+ supportsModelSelection: false,
275
+ supportsEnvironmentTest: false,
276
+ supportsWebSearch: false,
277
+ supportsThinkingEffort: false,
278
+ requiresRuntimeCwd: false
279
+ },
280
+ {
281
+ providerType: "shell",
282
+ label: "Shell",
283
+ supportsModelSelection: false,
284
+ supportsEnvironmentTest: true,
285
+ supportsWebSearch: false,
286
+ supportsThinkingEffort: false,
287
+ requiresRuntimeCwd: true
288
+ }
289
+ ];
290
+
291
+ const modelCatalog: Record<Exclude<AgentProviderType, "http" | "shell">, AdapterModelOption[]> = {
292
+ codex: [
293
+ { id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
294
+ { id: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark" },
295
+ { id: "gpt-5", label: "GPT-5" },
296
+ { id: "o3", label: "o3" },
297
+ { id: "o4-mini", label: "o4-mini" },
298
+ { id: "gpt-5-mini", label: "GPT-5 Mini" },
299
+ { id: "gpt-5-nano", label: "GPT-5 Nano" },
300
+ { id: "o3-mini", label: "o3-mini" },
301
+ { id: "codex-mini-latest", label: "Codex Mini" }
302
+ ],
303
+ claude_code: [
304
+ { id: "claude-opus-4-6", label: "Claude Opus 4.6" },
305
+ { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
306
+ { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }
307
+ ],
308
+ cursor: [
309
+ { id: "auto", label: "Auto" },
310
+ { id: "gpt-5.3-codex", label: "gpt-5.3-codex" },
311
+ { id: "gpt-5.3-codex-fast", label: "gpt-5.3-codex-fast" },
312
+ { id: "sonnet-4.5", label: "sonnet-4.5" },
313
+ { id: "opus-4.6", label: "opus-4.6" }
314
+ ],
315
+ opencode: []
316
+ };
317
+
318
+ export function listAdapterMetadata(): AdapterMetadata[] {
319
+ return staticMetadata;
320
+ }
321
+
322
+ export async function listAdapterModels(
323
+ providerType: AgentProviderType,
324
+ runtime?: AgentRuntimeConfig
325
+ ): Promise<AdapterModelOption[]> {
326
+ if (providerType === "http" || providerType === "shell") {
327
+ return [];
328
+ }
329
+ if (providerType === "cursor") {
330
+ const discovered = await discoverCursorModels(runtime);
331
+ return dedupeModels([...discovered, ...modelCatalog.cursor]);
332
+ }
333
+ if (providerType === "opencode") {
334
+ const discovered = await discoverOpenCodeModels(runtime);
335
+ return dedupeModels(discovered);
336
+ }
337
+ return modelCatalog[providerType];
338
+ }
339
+
340
+ export async function testAdapterEnvironment(
341
+ providerType: AgentProviderType,
342
+ runtime?: AgentRuntimeConfig
343
+ ): Promise<AdapterEnvironmentResult> {
344
+ const checks: AdapterEnvironmentCheck[] = [];
345
+ const command =
346
+ providerType === "cursor"
347
+ ? (await resolveCursorLaunchConfig(runtime)).command
348
+ : resolveRuntimeCommand(providerType, runtime);
349
+ const cwd = runtime?.cwd?.trim() || process.cwd();
350
+ const health = await checkRuntimeCommandHealth(command, { cwd, timeoutMs: 5_000 });
351
+
352
+ if (!health.available) {
353
+ checks.push({
354
+ code: "command_unavailable",
355
+ level: "error",
356
+ message: `Command is not executable: ${command}`,
357
+ detail: health.error
358
+ });
359
+ return {
360
+ providerType,
361
+ status: "fail",
362
+ testedAt: new Date().toISOString(),
363
+ checks
364
+ };
365
+ }
366
+
367
+ checks.push({
368
+ code: "command_available",
369
+ level: "info",
370
+ message: `Command is executable: ${command}`
371
+ });
372
+
373
+ if (providerType === "http") {
374
+ return { providerType, status: "pass", testedAt: new Date().toISOString(), checks };
375
+ }
376
+
377
+ if (providerType === "opencode" && !runtime?.model?.trim()) {
378
+ checks.push({
379
+ code: "model_missing",
380
+ level: "error",
381
+ message: "OpenCode requires a model in provider/model format."
382
+ });
383
+ }
384
+
385
+ const probe = await runRuntimeProbe(providerType, runtime);
386
+ if (probe.timedOut) {
387
+ checks.push({
388
+ code: "probe_timeout",
389
+ level: "warn",
390
+ message: "Environment probe timed out."
391
+ });
392
+ } else if (probe.ok) {
393
+ checks.push({
394
+ code: "probe_ok",
395
+ level: "info",
396
+ message: "Environment probe succeeded."
397
+ });
398
+ } else {
399
+ const detail = `${probe.stderr}\n${probe.stdout}`.trim().slice(0, 500);
400
+ const normalizedDetail = detail.toLowerCase();
401
+ if (
402
+ providerType === "codex" &&
403
+ normalizedDetail.includes("401 unauthorized") &&
404
+ (normalizedDetail.includes("missing bearer") || normalizedDetail.includes("authentication"))
405
+ ) {
406
+ checks.push({
407
+ code: "codex_auth_required",
408
+ level: "warn",
409
+ message: "Codex authentication is not ready for this runtime.",
410
+ detail,
411
+ hint: "Run `codex login` locally or provide OPENAI_API_KEY."
412
+ });
413
+ return {
414
+ providerType,
415
+ status: toEnvironmentStatus(checks),
416
+ testedAt: new Date().toISOString(),
417
+ checks
418
+ };
419
+ }
420
+ checks.push({
421
+ code: "probe_failed",
422
+ level: providerType === "codex" || providerType === "cursor" || providerType === "opencode" ? "warn" : "error",
423
+ message: "Environment probe failed.",
424
+ detail
425
+ });
426
+ }
427
+
428
+ return {
429
+ providerType,
430
+ status: toEnvironmentStatus(checks),
431
+ testedAt: new Date().toISOString(),
432
+ checks
433
+ };
434
+ }
435
+
108
436
  function createSkippedResult(providerLabel: string, providerKey: string, context: HeartbeatContext): AdapterExecutionResult {
109
437
  return {
110
438
  status: "skipped",
@@ -112,6 +440,14 @@ function createSkippedResult(providerLabel: string, providerKey: string, context
112
440
  tokenInput: 0,
113
441
  tokenOutput: 0,
114
442
  usdCost: 0,
443
+ outcome: toOutcome({
444
+ kind: "skipped",
445
+ issueIdsTouched: issueIdsTouched(context),
446
+ actions: [{ type: "heartbeat.skip", status: "warn", detail: summarizeWork(context) }],
447
+ blockers: [],
448
+ artifacts: [],
449
+ nextSuggestedState: "todo"
450
+ }),
115
451
  nextState: withProviderMetadata(context, providerKey)
116
452
  };
117
453
  }
@@ -124,6 +460,79 @@ async function runProviderWork(
124
460
  const prompt = createPrompt(context);
125
461
  const runtime = await executeAgentRuntime(provider, prompt, context.runtime);
126
462
  if (runtime.ok) {
463
+ if (!runtime.parsedUsage) {
464
+ const detail = buildMissingStructuredOutputDetail(provider, runtime);
465
+ return {
466
+ status: "failed",
467
+ summary: `${provider} runtime failed: ${detail}`,
468
+ tokenInput: 0,
469
+ tokenOutput: 0,
470
+ usdCost: 0,
471
+ outcome: toOutcome({
472
+ kind: "failed",
473
+ issueIdsTouched: issueIdsTouched(context),
474
+ actions: [{ type: "runtime.execute", status: "error", detail }],
475
+ blockers: [{ code: "missing_structured_output", message: detail, retryable: true }],
476
+ artifacts: [],
477
+ nextSuggestedState: "blocked"
478
+ }),
479
+ trace: {
480
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
481
+ args: runtime.argsUsed,
482
+ cwd: context.runtime?.cwd,
483
+ exitCode: runtime.code,
484
+ elapsedMs: runtime.elapsedMs,
485
+ timedOut: runtime.timedOut,
486
+ failureType: "missing_structured_output",
487
+ timeoutSource: runtime.timedOut ? "runtime" : null,
488
+ attemptCount: runtime.attemptCount,
489
+ attempts: runtime.attempts,
490
+ structuredOutputSource: runtime.structuredOutputSource,
491
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
492
+ stdoutPreview: toPreview(runtime.stdout),
493
+ stderrPreview: toPreview(runtime.stderr),
494
+ transcript: runtime.transcript
495
+ },
496
+ nextState: context.state
497
+ };
498
+ }
499
+ if (provider === "claude_code" && isClaudeRunIncomplete(runtime)) {
500
+ const detail = "Claude run reached max-turns before completing execution for this issue.";
501
+ return {
502
+ status: "failed",
503
+ summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${detail}`,
504
+ tokenInput: runtime.parsedUsage?.tokenInput ?? 0,
505
+ tokenOutput: runtime.parsedUsage?.tokenOutput ?? 0,
506
+ usdCost: runtime.parsedUsage?.usdCost ?? 0,
507
+ outcome: toOutcome({
508
+ kind: "failed",
509
+ issueIdsTouched: issueIdsTouched(context),
510
+ actions: [{ type: "runtime.execute", status: "error", detail }],
511
+ blockers: [{ code: "max_turns_reached", message: detail, retryable: true }],
512
+ artifacts: [],
513
+ nextSuggestedState: "blocked"
514
+ }),
515
+ trace: {
516
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
517
+ args: runtime.argsUsed,
518
+ cwd: context.runtime?.cwd,
519
+ exitCode: runtime.code,
520
+ elapsedMs: runtime.elapsedMs,
521
+ timedOut: runtime.timedOut,
522
+ failureType: "max_turns_reached",
523
+ timeoutSource: runtime.timedOut ? "runtime" : null,
524
+ usageSource: "structured",
525
+ attemptCount: runtime.attemptCount,
526
+ attempts: runtime.attempts,
527
+ structuredOutputSource: runtime.structuredOutputSource,
528
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
529
+ stdoutPreview: toPreview(runtime.stdout),
530
+ stderrPreview: toPreview(runtime.stderr),
531
+ transcript: runtime.transcript
532
+ },
533
+ nextState: context.state
534
+ };
535
+ }
127
536
  const fallbackOutputTokens = Math.max(Math.round(runtime.stdout.length / 4), 80);
128
537
  const fallbackInputTokens = Math.max(Math.round(prompt.length / 4), 120);
129
538
  const tokenInput = runtime.parsedUsage?.tokenInput ?? fallbackInputTokens;
@@ -139,43 +548,202 @@ async function runProviderWork(
139
548
  tokenInput,
140
549
  tokenOutput,
141
550
  usdCost,
551
+ outcome: toOutcome({
552
+ kind: "completed",
553
+ issueIdsTouched: issueIdsTouched(context),
554
+ actions: [{ type: "runtime.execute", status: "ok", detail: `${provider} runtime completed.` }],
555
+ blockers: [],
556
+ artifacts: [],
557
+ nextSuggestedState: "in_review"
558
+ }),
142
559
  trace: {
143
- command: context.runtime?.command ?? provider,
560
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
561
+ args: runtime.argsUsed,
562
+ cwd: context.runtime?.cwd,
144
563
  exitCode: runtime.code,
145
564
  elapsedMs: runtime.elapsedMs,
146
565
  timedOut: runtime.timedOut,
147
566
  failureType: runtime.failureType,
567
+ timeoutSource: runtime.timedOut ? "runtime" : null,
568
+ usageSource: "structured",
148
569
  attemptCount: runtime.attemptCount,
149
570
  attempts: runtime.attempts,
571
+ structuredOutputSource: runtime.structuredOutputSource,
572
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
150
573
  stdoutPreview: toPreview(runtime.stdout),
151
- stderrPreview: toPreview(runtime.stderr)
574
+ stderrPreview: toPreview(runtime.stderr),
575
+ transcript: runtime.transcript
152
576
  },
153
577
  nextState: withProviderMetadata(context, provider, runtime.elapsedMs, runtime.code)
154
578
  };
155
579
  }
156
580
  const failedUsage = resolveFailedUsage(runtime, prompt, pricing);
581
+ const failureDetail = resolveRuntimeFailureDetail(runtime);
157
582
  return {
158
583
  status: "failed",
159
- summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${runtime.stderr || "unknown error"}`,
584
+ summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${failureDetail}`,
160
585
  tokenInput: failedUsage.tokenInput,
161
586
  tokenOutput: failedUsage.tokenOutput,
162
587
  usdCost: failedUsage.usdCost,
588
+ outcome: toOutcome({
589
+ kind: "failed",
590
+ issueIdsTouched: issueIdsTouched(context),
591
+ actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
592
+ blockers: [{ code: runtime.failureType ?? "runtime_failed", message: failureDetail, retryable: true }],
593
+ artifacts: [],
594
+ nextSuggestedState: "blocked"
595
+ }),
163
596
  trace: {
164
- command: context.runtime?.command ?? provider,
597
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
598
+ args: runtime.argsUsed,
599
+ cwd: context.runtime?.cwd,
165
600
  exitCode: runtime.code,
166
601
  elapsedMs: runtime.elapsedMs,
167
602
  timedOut: runtime.timedOut,
168
603
  failureType: runtime.failureType,
604
+ timeoutSource: runtime.timedOut ? "runtime" : null,
169
605
  attemptCount: runtime.attemptCount,
170
606
  attempts: runtime.attempts,
171
607
  usageSource: failedUsage.source,
608
+ structuredOutputSource: runtime.structuredOutputSource,
609
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
172
610
  stdoutPreview: toPreview(runtime.stdout),
173
- stderrPreview: toPreview(runtime.stderr)
611
+ stderrPreview: toPreview(runtime.stderr),
612
+ transcript: runtime.transcript
174
613
  },
175
614
  nextState: context.state
176
615
  };
177
616
  }
178
617
 
618
+ async function runCursorWork(context: HeartbeatContext): Promise<AdapterExecutionResult> {
619
+ const prompt = createPrompt(context);
620
+ const cursorLaunch = await resolveCursorLaunchConfig(context.runtime);
621
+ const cwd = context.runtime?.cwd?.trim() || process.cwd();
622
+ const resumeState = resolveCursorResumeState(context.state, cwd);
623
+ const runtimeTimeoutMs = context.runtime?.timeoutMs && context.runtime.timeoutMs > 0 ? context.runtime.timeoutMs : 30_000;
624
+ const buildArgs = (resumeSessionId: string | null) => {
625
+ const baseArgs = [...cursorLaunch.prefixArgs, "-p", "--output-format", "stream-json", "--workspace", cwd];
626
+ if (resumeSessionId) {
627
+ baseArgs.push("--resume", resumeSessionId);
628
+ }
629
+ if (context.runtime?.model?.trim()) {
630
+ baseArgs.push("--model", context.runtime.model.trim());
631
+ }
632
+ if (!hasTrustFlag(context.runtime?.args ?? [])) {
633
+ baseArgs.push("--yolo");
634
+ }
635
+ return [...baseArgs, ...(context.runtime?.args ?? [])];
636
+ };
637
+ const runtime = await executePromptRuntime(
638
+ cursorLaunch.command,
639
+ prompt,
640
+ {
641
+ ...context.runtime,
642
+ timeoutMs: runtimeTimeoutMs,
643
+ retryCount: 0,
644
+ args: buildArgs(resumeState.resumeSessionId)
645
+ },
646
+ { provider: "cursor" }
647
+ );
648
+ const initialSessionId = readRuntimeSessionId(
649
+ runtime,
650
+ resumeState.resumeAttempted ? context.state.cursorSession?.sessionId ?? context.state.sessionId ?? null : null
651
+ );
652
+ if (!runtime.ok && resumeState.resumeSessionId && isUnknownSessionError(runtime.stderr, runtime.stdout)) {
653
+ const retry = await executePromptRuntime(
654
+ cursorLaunch.command,
655
+ prompt,
656
+ {
657
+ ...context.runtime,
658
+ timeoutMs: runtimeTimeoutMs,
659
+ retryCount: 0,
660
+ args: buildArgs(null)
661
+ },
662
+ { provider: "cursor" }
663
+ );
664
+ return toProviderResult(context, "cursor", prompt, retry, {
665
+ inputRate: 0.0000015,
666
+ outputRate: 0.000008
667
+ }, {
668
+ currentSessionId: readRuntimeSessionId(retry, null),
669
+ resumedSessionId: resumeState.resumeSessionId,
670
+ resumeAttempted: true,
671
+ clearedStaleSession: !readRuntimeSessionId(retry, null),
672
+ cwd
673
+ });
674
+ }
675
+ return toProviderResult(context, "cursor", prompt, runtime, {
676
+ inputRate: 0.0000015,
677
+ outputRate: 0.000008
678
+ }, {
679
+ currentSessionId: initialSessionId,
680
+ resumedSessionId: resumeState.resumeSessionId,
681
+ resumeAttempted: resumeState.resumeAttempted,
682
+ resumeSkippedReason: resumeState.resumeSkippedReason,
683
+ clearedStaleSession: resumeState.resumeSkippedReason === "cwd_mismatch" && !initialSessionId,
684
+ cwd
685
+ });
686
+ }
687
+
688
+ async function runOpenCodeWork(context: HeartbeatContext): Promise<AdapterExecutionResult> {
689
+ const prompt = createPrompt(context);
690
+ const model = context.runtime?.model?.trim();
691
+ if (!model) {
692
+ return {
693
+ status: "failed",
694
+ summary: "opencode runtime requires runtimeModel in provider/model format.",
695
+ tokenInput: 0,
696
+ tokenOutput: 0,
697
+ usdCost: 0,
698
+ outcome: toOutcome({
699
+ kind: "blocked",
700
+ issueIdsTouched: issueIdsTouched(context),
701
+ actions: [{ type: "runtime.validate", status: "error", detail: "Missing runtimeModel." }],
702
+ blockers: [{ code: "model_missing", message: "runtimeModel in provider/model format is required.", retryable: false }],
703
+ artifacts: [],
704
+ nextSuggestedState: "blocked"
705
+ }),
706
+ nextState: context.state
707
+ };
708
+ }
709
+ const resumeSessionId = context.state.sessionId?.trim();
710
+ const baseArgs = ["run", "--format", "json", "--model", model];
711
+ if (resumeSessionId) {
712
+ baseArgs.push("--session", resumeSessionId);
713
+ }
714
+ const runtime = await executePromptRuntime(
715
+ context.runtime?.command ?? "opencode",
716
+ prompt,
717
+ {
718
+ ...context.runtime,
719
+ args: [...baseArgs, ...(context.runtime?.args ?? [])]
720
+ },
721
+ { provider: "opencode" }
722
+ );
723
+ const parsed = parseOpenCodeOutput(runtime.stdout);
724
+ if (!runtime.ok && resumeSessionId && isUnknownSessionError(runtime.stderr, runtime.stdout)) {
725
+ const retry = await executePromptRuntime(
726
+ context.runtime?.command ?? "opencode",
727
+ prompt,
728
+ {
729
+ ...context.runtime,
730
+ args: ["run", "--format", "json", "--model", model, ...(context.runtime?.args ?? [])]
731
+ },
732
+ { provider: "opencode" }
733
+ );
734
+ return toProviderResult(context, "opencode", prompt, retry, {
735
+ inputRate: 0.0000015,
736
+ outputRate: 0.000008
737
+ });
738
+ }
739
+ return toProviderResult(context, "opencode", prompt, runtime, {
740
+ inputRate: 0.0000015,
741
+ outputRate: 0.000008
742
+ }, {
743
+ currentSessionId: parsed.sessionId ?? null
744
+ });
745
+ }
746
+
179
747
  function resolveFailedUsage(
180
748
  runtime: {
181
749
  parsedUsage?: {
@@ -218,6 +786,482 @@ function resolveFailedUsage(
218
786
  };
219
787
  }
220
788
 
789
+ function toProviderResult(
790
+ context: HeartbeatContext,
791
+ provider: AgentProviderType,
792
+ prompt: string,
793
+ runtime: {
794
+ ok: boolean;
795
+ code: number | null;
796
+ stdout: string;
797
+ stderr: string;
798
+ timedOut: boolean;
799
+ elapsedMs: number;
800
+ attemptCount: number;
801
+ failureType?: "timeout" | "spawn_error" | "nonzero_exit";
802
+ attempts: Array<{
803
+ attempt: number;
804
+ code: number | null;
805
+ timedOut: boolean;
806
+ elapsedMs: number;
807
+ signal: NodeJS.Signals | null;
808
+ spawnErrorCode?: string;
809
+ forcedKill: boolean;
810
+ }>;
811
+ parsedUsage?: {
812
+ tokenInput?: number;
813
+ tokenOutput?: number;
814
+ usdCost?: number;
815
+ summary?: string;
816
+ };
817
+ structuredOutputSource?: "stdout" | "stderr";
818
+ structuredOutputDiagnostics?: {
819
+ stdoutJsonObjectCount: number;
820
+ stderrJsonObjectCount: number;
821
+ stderrStructuredUsageDetected: boolean;
822
+ stdoutBytes: number;
823
+ stderrBytes: number;
824
+ hasAnyOutput: boolean;
825
+ lastStdoutLine?: string;
826
+ lastStderrLine?: string;
827
+ likelyCause:
828
+ | "no_output_from_runtime"
829
+ | "json_missing"
830
+ | "json_on_stderr_only"
831
+ | "schema_or_shape_mismatch";
832
+ claudeStopReason?: string;
833
+ claudeResultSubtype?: string;
834
+ claudeSessionId?: string;
835
+ claudeContract?: {
836
+ commandOverride: boolean;
837
+ commandLooksClaude: boolean;
838
+ commandWasProviderAlias: boolean;
839
+ hasPromptFlag: boolean;
840
+ hasOutputFormatJson: boolean;
841
+ outputFormat: string | null;
842
+ hasMaxTurnsFlag: boolean;
843
+ hasVerboseFlag: boolean;
844
+ hasDangerouslySkipPermissions: boolean;
845
+ hasJsonSchema: boolean;
846
+ missingRequiredArgs: string[];
847
+ };
848
+ };
849
+ commandUsed?: string;
850
+ argsUsed?: string[];
851
+ transcript?: Array<{
852
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
853
+ label?: string;
854
+ text?: string;
855
+ payload?: string;
856
+ }>;
857
+ },
858
+ pricing: { inputRate: number; outputRate: number },
859
+ sessionUpdate?: ProviderSessionUpdate
860
+ ): AdapterExecutionResult {
861
+ if (runtime.ok) {
862
+ if (!runtime.parsedUsage) {
863
+ const detail = buildMissingStructuredOutputDetail(provider, runtime);
864
+ return {
865
+ status: "failed",
866
+ summary: `${provider} runtime failed: ${detail}`,
867
+ tokenInput: 0,
868
+ tokenOutput: 0,
869
+ usdCost: 0,
870
+ outcome: toOutcome({
871
+ kind: "failed",
872
+ issueIdsTouched: issueIdsTouched(context),
873
+ actions: [{ type: "runtime.execute", status: "error", detail }],
874
+ blockers: [{ code: "missing_structured_output", message: detail, retryable: true }],
875
+ artifacts: [],
876
+ nextSuggestedState: "blocked"
877
+ }),
878
+ trace: {
879
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
880
+ args: runtime.argsUsed,
881
+ cwd: context.runtime?.cwd,
882
+ exitCode: runtime.code,
883
+ elapsedMs: runtime.elapsedMs,
884
+ timedOut: runtime.timedOut,
885
+ failureType: "missing_structured_output",
886
+ timeoutSource: runtime.timedOut ? "runtime" : null,
887
+ attemptCount: runtime.attemptCount,
888
+ attempts: runtime.attempts,
889
+ session: sessionUpdate,
890
+ structuredOutputSource: runtime.structuredOutputSource,
891
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
892
+ stdoutPreview: toPreview(runtime.stdout),
893
+ stderrPreview: toPreview(runtime.stderr),
894
+ transcript: runtime.transcript
895
+ },
896
+ nextState: applyProviderSessionState(context, provider, sessionUpdate)
897
+ };
898
+ }
899
+ const fallbackOutputTokens = Math.max(Math.round(runtime.stdout.length / 4), 80);
900
+ const fallbackInputTokens = Math.max(Math.round(prompt.length / 4), 120);
901
+ const tokenInput = runtime.parsedUsage?.tokenInput ?? fallbackInputTokens;
902
+ const tokenOutput = runtime.parsedUsage?.tokenOutput ?? fallbackOutputTokens;
903
+ const usdCost =
904
+ runtime.parsedUsage?.usdCost ??
905
+ Number((tokenInput * pricing.inputRate + tokenOutput * pricing.outputRate).toFixed(6));
906
+ const summary = runtime.parsedUsage?.summary ?? `${provider} runtime finished in ${runtime.elapsedMs}ms.`;
907
+ return {
908
+ status: "ok",
909
+ summary,
910
+ tokenInput,
911
+ tokenOutput,
912
+ usdCost,
913
+ outcome: toOutcome({
914
+ kind: "completed",
915
+ issueIdsTouched: issueIdsTouched(context),
916
+ actions: [{ type: "runtime.execute", status: "ok", detail: `${provider} runtime completed.` }],
917
+ blockers: [],
918
+ artifacts: [],
919
+ nextSuggestedState: "in_review"
920
+ }),
921
+ trace: {
922
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
923
+ args: runtime.argsUsed,
924
+ cwd: context.runtime?.cwd,
925
+ exitCode: runtime.code,
926
+ elapsedMs: runtime.elapsedMs,
927
+ timedOut: runtime.timedOut,
928
+ failureType: runtime.failureType,
929
+ timeoutSource: runtime.timedOut ? "runtime" : null,
930
+ usageSource: "structured",
931
+ attemptCount: runtime.attemptCount,
932
+ attempts: runtime.attempts,
933
+ session: sessionUpdate,
934
+ structuredOutputSource: runtime.structuredOutputSource,
935
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
936
+ stdoutPreview: toPreview(runtime.stdout),
937
+ stderrPreview: toPreview(runtime.stderr),
938
+ transcript: runtime.transcript
939
+ },
940
+ nextState: applyProviderSessionState(context, provider, sessionUpdate, runtime.elapsedMs, runtime.code)
941
+ };
942
+ }
943
+ const failedUsage = resolveFailedUsage(runtime, prompt, pricing);
944
+ const failureDetail = resolveRuntimeFailureDetail(runtime);
945
+ return {
946
+ status: "failed",
947
+ summary: runtime.parsedUsage?.summary ?? `${provider} runtime failed: ${failureDetail}`,
948
+ tokenInput: failedUsage.tokenInput,
949
+ tokenOutput: failedUsage.tokenOutput,
950
+ usdCost: failedUsage.usdCost,
951
+ outcome: toOutcome({
952
+ kind: "failed",
953
+ issueIdsTouched: issueIdsTouched(context),
954
+ actions: [{ type: "runtime.execute", status: "error", detail: failureDetail }],
955
+ blockers: [{ code: runtime.failureType ?? "runtime_failed", message: failureDetail, retryable: true }],
956
+ artifacts: [],
957
+ nextSuggestedState: "blocked"
958
+ }),
959
+ trace: {
960
+ command: runtime.commandUsed ?? context.runtime?.command ?? provider,
961
+ args: runtime.argsUsed,
962
+ cwd: context.runtime?.cwd,
963
+ exitCode: runtime.code,
964
+ elapsedMs: runtime.elapsedMs,
965
+ timedOut: runtime.timedOut,
966
+ failureType: runtime.failureType,
967
+ timeoutSource: runtime.timedOut ? "runtime" : null,
968
+ attemptCount: runtime.attemptCount,
969
+ attempts: runtime.attempts,
970
+ usageSource: failedUsage.source,
971
+ session: sessionUpdate,
972
+ structuredOutputSource: runtime.structuredOutputSource,
973
+ structuredOutputDiagnostics: runtime.structuredOutputDiagnostics,
974
+ stdoutPreview: toPreview(runtime.stdout),
975
+ stderrPreview: toPreview(runtime.stderr),
976
+ transcript: runtime.transcript
977
+ },
978
+ nextState: applyProviderSessionState(context, provider, sessionUpdate)
979
+ };
980
+ }
981
+
982
+ function resolveRuntimeFailureDetail(runtime: {
983
+ stderr: string;
984
+ stdout: string;
985
+ code: number | null;
986
+ failureType?: "timeout" | "spawn_error" | "nonzero_exit";
987
+ attempts: Array<{ spawnErrorCode?: string }>;
988
+ }) {
989
+ const stderr = runtime.stderr.trim();
990
+ if (stderr.length > 0) {
991
+ return stderr;
992
+ }
993
+ const lastAttempt = runtime.attempts[runtime.attempts.length - 1];
994
+ if (runtime.failureType === "spawn_error") {
995
+ if (lastAttempt?.spawnErrorCode) {
996
+ return `failed to launch runtime command (${lastAttempt.spawnErrorCode}). Verify the CLI is installed and on PATH.`;
997
+ }
998
+ return "failed to launch runtime command. Verify the CLI is installed and on PATH.";
999
+ }
1000
+ if (runtime.failureType === "timeout") {
1001
+ return "timed out before completion. Increase runtimeTimeoutSec for this agent/runtime.";
1002
+ }
1003
+ if (runtime.code !== null) {
1004
+ return `process exited with code ${runtime.code} without stderr output.`;
1005
+ }
1006
+ const stdout = runtime.stdout.trim();
1007
+ if (stdout.length > 0) {
1008
+ return `no stderr output; stdout preview: ${toPreview(stdout, 320)}`;
1009
+ }
1010
+ return "runtime exited without diagnostic output.";
1011
+ }
1012
+
1013
+ function parseOpenCodeOutput(stdout: string) {
1014
+ let sessionId: string | null = null;
1015
+ for (const line of stdout.split(/\r?\n/)) {
1016
+ const trimmed = line.trim();
1017
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) continue;
1018
+ try {
1019
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
1020
+ const maybeSession =
1021
+ (typeof parsed.sessionID === "string" && parsed.sessionID) ||
1022
+ (typeof parsed.sessionId === "string" && parsed.sessionId) ||
1023
+ null;
1024
+ if (maybeSession) {
1025
+ sessionId = maybeSession;
1026
+ }
1027
+ } catch {
1028
+ // ignore parser noise
1029
+ }
1030
+ }
1031
+ return { sessionId };
1032
+ }
1033
+
1034
+ function resolveCursorResumeState(state: HeartbeatContext["state"], cwd: string) {
1035
+ const savedSessionId = state.cursorSession?.sessionId?.trim() || state.sessionId?.trim() || null;
1036
+ const savedCwd = state.cursorSession?.cwd?.trim() || state.cwd?.trim() || null;
1037
+ if (!savedSessionId) {
1038
+ return {
1039
+ resumeSessionId: null,
1040
+ resumeAttempted: false,
1041
+ resumeSkippedReason: null
1042
+ };
1043
+ }
1044
+ if (savedCwd && resolve(savedCwd) !== resolve(cwd)) {
1045
+ return {
1046
+ resumeSessionId: null,
1047
+ resumeAttempted: false,
1048
+ resumeSkippedReason: "cwd_mismatch"
1049
+ };
1050
+ }
1051
+ return {
1052
+ resumeSessionId: savedSessionId,
1053
+ resumeAttempted: true,
1054
+ resumeSkippedReason: null
1055
+ };
1056
+ }
1057
+
1058
+ function readRuntimeSessionId(
1059
+ runtime: {
1060
+ structuredOutputDiagnostics?: {
1061
+ claudeSessionId?: string;
1062
+ cursorSessionId?: string;
1063
+ };
1064
+ },
1065
+ fallback: string | null
1066
+ ) {
1067
+ return runtime.structuredOutputDiagnostics?.cursorSessionId?.trim() || runtime.structuredOutputDiagnostics?.claudeSessionId?.trim() || fallback;
1068
+ }
1069
+
1070
+ function isUnknownSessionError(stderr: string, stdout: string) {
1071
+ const haystack = `${stderr}\n${stdout}`.toLowerCase();
1072
+ return (
1073
+ haystack.includes("unknown session") ||
1074
+ haystack.includes("session not found") ||
1075
+ haystack.includes("could not resume")
1076
+ );
1077
+ }
1078
+
1079
+ function hasTrustFlag(args: string[]) {
1080
+ return args.includes("--trust") || args.includes("--yolo") || args.includes("-f");
1081
+ }
1082
+
1083
+ function resolveRuntimeCommand(providerType: AgentProviderType, runtime?: AgentRuntimeConfig) {
1084
+ if (runtime?.command?.trim()) return runtime.command.trim();
1085
+ if (providerType === "claude_code") return "claude";
1086
+ if (providerType === "codex") return "codex";
1087
+ if (providerType === "cursor") return "cursor";
1088
+ if (providerType === "opencode") return "opencode";
1089
+ return providerType;
1090
+ }
1091
+
1092
+ async function runRuntimeProbe(providerType: AgentProviderType, runtime?: AgentRuntimeConfig) {
1093
+ const prompt = "Respond with hello.";
1094
+ if (providerType === "claude_code" || providerType === "codex") {
1095
+ return executeAgentRuntime(providerType, prompt, {
1096
+ ...runtime,
1097
+ retryCount: 0,
1098
+ timeoutMs: runtime?.timeoutMs ? Math.min(runtime.timeoutMs, 45_000) : 45_000
1099
+ });
1100
+ }
1101
+ if (providerType === "cursor") {
1102
+ const cursorLaunch = await resolveCursorLaunchConfig(runtime);
1103
+ const cwd = runtime?.cwd?.trim() || process.cwd();
1104
+ return executePromptRuntime(
1105
+ cursorLaunch.command,
1106
+ prompt,
1107
+ {
1108
+ ...runtime,
1109
+ args: [...cursorLaunch.prefixArgs, "-p", "--output-format", "stream-json", "--workspace", cwd, ...(runtime?.args ?? [])],
1110
+ retryCount: 0,
1111
+ timeoutMs: runtime?.timeoutMs ? Math.min(runtime.timeoutMs, 45_000) : 45_000
1112
+ },
1113
+ { provider: "cursor" }
1114
+ );
1115
+ }
1116
+ if (providerType === "opencode") {
1117
+ const model = runtime?.model?.trim();
1118
+ return executePromptRuntime(
1119
+ resolveRuntimeCommand(providerType, runtime),
1120
+ prompt,
1121
+ {
1122
+ ...runtime,
1123
+ args: model
1124
+ ? ["run", "--format", "json", "--model", model, ...(runtime?.args ?? [])]
1125
+ : ["run", "--format", "json", ...(runtime?.args ?? [])],
1126
+ retryCount: 0,
1127
+ timeoutMs: runtime?.timeoutMs ? Math.min(runtime.timeoutMs, 45_000) : 45_000
1128
+ },
1129
+ { provider: "opencode" }
1130
+ );
1131
+ }
1132
+ return executePromptRuntime(resolveRuntimeCommand(providerType, runtime), prompt, {
1133
+ ...runtime,
1134
+ retryCount: 0,
1135
+ timeoutMs: runtime?.timeoutMs ? Math.min(runtime.timeoutMs, 45_000) : 45_000
1136
+ });
1137
+ }
1138
+
1139
+ async function discoverCursorModels(runtime?: AgentRuntimeConfig): Promise<AdapterModelOption[]> {
1140
+ const cursorLaunch = await resolveCursorLaunchConfig(runtime);
1141
+ const probe = await executePromptRuntime(
1142
+ cursorLaunch.command,
1143
+ "models",
1144
+ {
1145
+ ...runtime,
1146
+ args: [...cursorLaunch.prefixArgs, "models"],
1147
+ timeoutMs: 8_000,
1148
+ retryCount: 0
1149
+ },
1150
+ { provider: "cursor" }
1151
+ );
1152
+ if (!probe.ok && probe.stdout.trim().length === 0 && probe.stderr.trim().length === 0) {
1153
+ return [];
1154
+ }
1155
+ return parseModelLines(`${probe.stdout}\n${probe.stderr}`);
1156
+ }
1157
+
1158
+ async function discoverOpenCodeModels(runtime?: AgentRuntimeConfig): Promise<AdapterModelOption[]> {
1159
+ const probe = await executePromptRuntime(
1160
+ resolveRuntimeCommand("opencode", runtime),
1161
+ "models",
1162
+ {
1163
+ ...runtime,
1164
+ args: ["models"],
1165
+ timeoutMs: 10_000,
1166
+ retryCount: 0
1167
+ },
1168
+ { provider: "opencode" }
1169
+ );
1170
+ if (!probe.ok && !probe.stdout.trim()) {
1171
+ return [];
1172
+ }
1173
+ return parseModelLines(probe.stdout).filter((entry) => entry.id.includes("/"));
1174
+ }
1175
+
1176
+ function parseModelLines(text: string): AdapterModelOption[] {
1177
+ const out: AdapterModelOption[] = [];
1178
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1179
+ for (const line of lines) {
1180
+ if (line.startsWith("{") || line.startsWith("[")) {
1181
+ try {
1182
+ const parsed = JSON.parse(line) as unknown;
1183
+ if (Array.isArray(parsed)) {
1184
+ for (const entry of parsed) {
1185
+ if (typeof entry === "string") {
1186
+ out.push({ id: entry, label: entry });
1187
+ } else if (entry && typeof entry === "object") {
1188
+ const id = (entry as Record<string, unknown>).id;
1189
+ if (typeof id === "string" && id.trim()) out.push({ id, label: id });
1190
+ }
1191
+ }
1192
+ }
1193
+ } catch {
1194
+ // ignore
1195
+ }
1196
+ continue;
1197
+ }
1198
+ const first = line.replace(/^[-*]\s+/, "").split(/\s+/)[0];
1199
+ if (first && /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(first)) {
1200
+ out.push({ id: first, label: first });
1201
+ }
1202
+ }
1203
+ return dedupeModels(out);
1204
+ }
1205
+
1206
+ function dedupeModels(models: AdapterModelOption[]) {
1207
+ const seen = new Set<string>();
1208
+ const deduped: AdapterModelOption[] = [];
1209
+ for (const model of models) {
1210
+ const id = model.id.trim();
1211
+ if (!id || seen.has(id)) continue;
1212
+ seen.add(id);
1213
+ deduped.push({ id, label: model.label.trim() || id });
1214
+ }
1215
+ return deduped;
1216
+ }
1217
+
1218
+ async function resolveCursorLaunchConfig(runtime?: AgentRuntimeConfig): Promise<{ command: string; prefixArgs: string[] }> {
1219
+ const configuredCommand = runtime?.command?.trim();
1220
+ if (configuredCommand) {
1221
+ const commandToken = configuredCommand.split(/[\\/]/).pop()?.toLowerCase() ?? configuredCommand.toLowerCase();
1222
+ const configuredArgs = runtime?.args ?? [];
1223
+ const hasAgentSubcommand = configuredArgs[0]?.toLowerCase() === "agent";
1224
+ if (commandToken === "cursor" || commandToken === "cursor.exe") {
1225
+ return { command: configuredCommand, prefixArgs: hasAgentSubcommand ? [] : ["agent"] };
1226
+ }
1227
+ if (commandToken !== "agent" && commandToken !== "agent.exe") {
1228
+ return { command: configuredCommand, prefixArgs: [] };
1229
+ }
1230
+ const configuredHealth = await checkRuntimeCommandHealth(configuredCommand, {
1231
+ cwd: runtime?.cwd?.trim() || process.cwd(),
1232
+ timeoutMs: 1_500
1233
+ });
1234
+ if (configuredHealth.available) {
1235
+ return { command: configuredCommand, prefixArgs: [] };
1236
+ }
1237
+ }
1238
+ const candidates: Array<{ command: string; prefixArgs: string[] }> = [
1239
+ { command: "agent", prefixArgs: [] },
1240
+ { command: "cursor", prefixArgs: ["agent"] },
1241
+ { command: join("/Applications", "Cursor.app", "Contents", "Resources", "app", "bin", "cursor"), prefixArgs: ["agent"] },
1242
+ {
1243
+ command: join(homedir(), "Applications", "Cursor.app", "Contents", "Resources", "app", "bin", "cursor"),
1244
+ prefixArgs: ["agent"]
1245
+ }
1246
+ ];
1247
+ for (const candidate of candidates) {
1248
+ const health = await checkRuntimeCommandHealth(candidate.command, {
1249
+ cwd: runtime?.cwd?.trim() || process.cwd(),
1250
+ timeoutMs: 1_500
1251
+ });
1252
+ if (health.available) {
1253
+ return candidate;
1254
+ }
1255
+ }
1256
+ return { command: "agent", prefixArgs: [] };
1257
+ }
1258
+
1259
+ function toEnvironmentStatus(checks: AdapterEnvironmentCheck[]): "pass" | "warn" | "fail" {
1260
+ if (checks.some((check) => check.level === "error")) return "fail";
1261
+ if (checks.some((check) => check.level === "warn")) return "warn";
1262
+ return "pass";
1263
+ }
1264
+
221
1265
  function createPrompt(context: HeartbeatContext) {
222
1266
  const bootstrapPrompt = context.runtime?.bootstrapPrompt?.trim();
223
1267
  const companyGoals = context.goalContext?.companyGoals.length
@@ -249,8 +1293,8 @@ function createPrompt(context: HeartbeatContext) {
249
1293
 
250
1294
  const executionDirectives = [
251
1295
  "Execution directives:",
252
- "- You are running inside a BopoHQ heartbeat for local repository work.",
253
- "- Use BopoHQ-specific injected skills only (bopohq-control-plane, bopohq-create-agent, para-memory-files) when relevant.",
1296
+ "- You are running inside a BopoDev heartbeat for local repository work.",
1297
+ "- Use BopoDev-specific injected skills only (bopodev-control-plane, bopodev-create-agent, para-memory-files) when relevant.",
254
1298
  "- Ignore unrelated third-party control-plane skills even if they exist in the runtime environment.",
255
1299
  "- Prefer completing assigned issue work in this repository over non-essential coordination tasks.",
256
1300
  "- Keep command usage minimal and task-focused; avoid broad repository scans unless strictly required for the assigned issue.",
@@ -258,6 +1302,8 @@ function createPrompt(context: HeartbeatContext) {
258
1302
  "- Prefer POSIX/zsh-compatible shell snippets, direct `curl` headers, `jq`, and temp JSON files/heredocs.",
259
1303
  "- If control-plane API connectivity fails, report the exact failing command/error once and stop retry loops for the same endpoint.",
260
1304
  "- If any command fails, avoid further exploratory commands and still return the required final JSON summary.",
1305
+ "- Do not stop after planning. You must execute concrete steps for assigned issues in this run (file edits, API calls, or other verifiable actions).",
1306
+ "- If you cannot complete concrete execution, set summary to include the blocker explicitly instead of claiming success.",
261
1307
  "- Your final output must be only the JSON object below, with no prose before or after it.",
262
1308
  "- Do not invent token or cost values; the runtime records usage separately."
263
1309
  ].join("\n");
@@ -305,9 +1351,138 @@ function withProviderMetadata(
305
1351
  };
306
1352
  }
307
1353
 
1354
+ function applyProviderSessionState(
1355
+ context: HeartbeatContext,
1356
+ provider: AgentProviderType,
1357
+ sessionUpdate?: ProviderSessionUpdate,
1358
+ lastRuntimeMs?: number,
1359
+ lastExitCode?: number | null
1360
+ ) {
1361
+ const nextState = withProviderMetadata(context, provider, lastRuntimeMs, lastExitCode) as HeartbeatContext["state"] &
1362
+ Record<string, unknown>;
1363
+ if (provider === "cursor") {
1364
+ delete nextState.sessionId;
1365
+ delete nextState.cwd;
1366
+ delete nextState.cursorSession;
1367
+ const nextSessionId = sessionUpdate?.currentSessionId?.trim();
1368
+ const nextCwd = sessionUpdate?.cwd?.trim() || context.runtime?.cwd?.trim() || undefined;
1369
+ if (nextSessionId) {
1370
+ nextState.sessionId = nextSessionId;
1371
+ nextState.cwd = nextCwd;
1372
+ nextState.cursorSession = {
1373
+ sessionId: nextSessionId,
1374
+ ...(nextCwd ? { cwd: nextCwd } : {})
1375
+ };
1376
+ }
1377
+ return nextState;
1378
+ }
1379
+ if (provider === "opencode") {
1380
+ if (sessionUpdate?.currentSessionId?.trim()) {
1381
+ nextState.sessionId = sessionUpdate.currentSessionId.trim();
1382
+ }
1383
+ return nextState;
1384
+ }
1385
+ return nextState;
1386
+ }
1387
+
308
1388
  function toPreview(value: string, max = 1600) {
309
1389
  if (value.length <= max) {
310
1390
  return value;
311
1391
  }
312
1392
  return `${value.slice(0, max)}\n...[truncated]`;
313
1393
  }
1394
+
1395
+ function buildMissingStructuredOutputDetail(
1396
+ provider: string,
1397
+ runtime: {
1398
+ structuredOutputSource?: "stdout" | "stderr";
1399
+ structuredOutputDiagnostics?: {
1400
+ stdoutJsonObjectCount: number;
1401
+ stderrJsonObjectCount: number;
1402
+ stderrStructuredUsageDetected: boolean;
1403
+ stdoutBytes: number;
1404
+ stderrBytes: number;
1405
+ hasAnyOutput: boolean;
1406
+ lastStdoutLine?: string;
1407
+ lastStderrLine?: string;
1408
+ likelyCause:
1409
+ | "no_output_from_runtime"
1410
+ | "json_missing"
1411
+ | "json_on_stderr_only"
1412
+ | "schema_or_shape_mismatch";
1413
+ claudeStopReason?: string;
1414
+ claudeResultSubtype?: string;
1415
+ claudeSessionId?: string;
1416
+ claudeContract?: {
1417
+ commandOverride: boolean;
1418
+ commandLooksClaude: boolean;
1419
+ commandWasProviderAlias: boolean;
1420
+ hasPromptFlag: boolean;
1421
+ hasOutputFormatJson: boolean;
1422
+ outputFormat: string | null;
1423
+ hasMaxTurnsFlag: boolean;
1424
+ hasVerboseFlag: boolean;
1425
+ hasDangerouslySkipPermissions: boolean;
1426
+ hasJsonSchema: boolean;
1427
+ missingRequiredArgs: string[];
1428
+ };
1429
+ };
1430
+ }
1431
+ ) {
1432
+ const hints: string[] = [];
1433
+ const claudeContract = runtime.structuredOutputDiagnostics?.claudeContract;
1434
+ if (provider === "claude_code" && claudeContract) {
1435
+ if (claudeContract.commandWasProviderAlias) {
1436
+ hints.push("runtimeCommand used provider alias 'claude_code' and was normalized to 'claude'");
1437
+ }
1438
+ if (claudeContract.commandOverride && !claudeContract.commandLooksClaude) {
1439
+ hints.push("runtimeCommand override does not look like Claude CLI");
1440
+ }
1441
+ if (claudeContract.missingRequiredArgs.length > 0) {
1442
+ hints.push(`missing Claude structured-output args: ${claudeContract.missingRequiredArgs.join(", ")}`);
1443
+ }
1444
+ }
1445
+ if (runtime.structuredOutputSource === "stderr") {
1446
+ hints.push("structured JSON was detected on stderr");
1447
+ }
1448
+ const stdoutJsonObjectCount = runtime.structuredOutputDiagnostics?.stdoutJsonObjectCount ?? 0;
1449
+ const stderrJsonObjectCount = runtime.structuredOutputDiagnostics?.stderrJsonObjectCount ?? 0;
1450
+ const stdoutBytes = runtime.structuredOutputDiagnostics?.stdoutBytes ?? 0;
1451
+ const stderrBytes = runtime.structuredOutputDiagnostics?.stderrBytes ?? 0;
1452
+ const likelyCause = runtime.structuredOutputDiagnostics?.likelyCause ?? "json_missing";
1453
+ const hasAnyOutput = runtime.structuredOutputDiagnostics?.hasAnyOutput ?? false;
1454
+ const lastStdoutLine = runtime.structuredOutputDiagnostics?.lastStdoutLine;
1455
+ const lastStderrLine = runtime.structuredOutputDiagnostics?.lastStderrLine;
1456
+ const base =
1457
+ provider === "claude_code"
1458
+ ? "runtime completed without structured heartbeat output. Expected Claude stream-json events with a final result payload."
1459
+ : provider === "cursor"
1460
+ ? "runtime completed without structured heartbeat output. Expected Cursor stream-json events with assistant/result payloads."
1461
+ : "runtime completed without structured heartbeat JSON output. Ensure final output includes a single-line JSON object with summary/tokenInput/tokenOutput/usdCost.";
1462
+ const diagnostics = [
1463
+ `likelyCause=${likelyCause}`,
1464
+ `hasAnyOutput=${hasAnyOutput}`,
1465
+ `stdoutBytes=${stdoutBytes}`,
1466
+ `stderrBytes=${stderrBytes}`,
1467
+ `stdoutJsonObjects=${stdoutJsonObjectCount}`,
1468
+ `stderrJsonObjects=${stderrJsonObjectCount}`
1469
+ ];
1470
+ if (lastStdoutLine) {
1471
+ diagnostics.push(`lastStdoutLine=${JSON.stringify(lastStdoutLine).slice(0, 180)}`);
1472
+ }
1473
+ if (lastStderrLine) {
1474
+ diagnostics.push(`lastStderrLine=${JSON.stringify(lastStderrLine).slice(0, 180)}`);
1475
+ }
1476
+ return hints.length > 0 ? `${base} Diagnostics: ${[...hints, ...diagnostics].join("; ")}.` : `${base} Diagnostics: ${diagnostics.join("; ")}.`;
1477
+ }
1478
+
1479
+ function isClaudeRunIncomplete(runtime: {
1480
+ structuredOutputDiagnostics?: {
1481
+ claudeStopReason?: string;
1482
+ claudeResultSubtype?: string;
1483
+ };
1484
+ }) {
1485
+ const stopReason = runtime.structuredOutputDiagnostics?.claudeStopReason;
1486
+ const subtype = runtime.structuredOutputDiagnostics?.claudeResultSubtype;
1487
+ return stopReason === "max_turns" || subtype === "error_max_turns";
1488
+ }