@tagma/sdk 0.4.19 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,20 @@
1
1
  // ═══ PipelineRunner ═══
2
2
  //
3
- // Wraps runPipeline in a lifecycle object suited for multi-pipeline management
4
- // in sidecar / Tauri IPC scenarios. Each instance controls one pipeline run.
3
+ // Wraps runPipeline in a lifecycle object suited for multi-pipeline
4
+ // management in sidecar / Tauri IPC scenarios. Each instance controls
5
+ // one pipeline run.
6
+ //
7
+ // The runner forwards wire-shape `RunEventPayload` values to its
8
+ // subscribers — identical to what the editor server broadcasts over SSE —
9
+ // so sidecar hosts don't need to know anything about the engine's
10
+ // internal TaskState.
5
11
  //
6
12
  // Typical sidecar usage:
7
13
  //
8
14
  // const runners = new Map<string, PipelineRunner>();
9
15
  //
10
16
  // const runner = new PipelineRunner(config, workDir);
11
- // runner.subscribe(event => ipcEmit('pipeline_event', event));
17
+ // runner.subscribe(event => ipcEmit('run_event', event));
12
18
  // runner.start();
13
19
  // runners.set(runner.instanceId, runner);
14
20
  //
@@ -16,33 +22,37 @@
16
22
  // runners.get(id)?.abort();
17
23
 
18
24
  import { runPipeline } from './engine';
19
- import type { EngineResult, PipelineEvent, RunPipelineOptions } from './engine';
20
- import type { PipelineConfig, TaskState } from './types';
25
+ import type { EngineResult, RunPipelineOptions } from './engine';
26
+ import type { PipelineConfig, RunEventPayload, RunTaskState } from './types';
21
27
  import { generateRunId } from './utils';
22
28
 
23
- export type { PipelineEvent, EngineResult };
29
+ export type { EngineResult };
24
30
 
25
31
  export type PipelineRunnerStatus = 'idle' | 'running' | 'done' | 'aborted';
26
32
 
27
33
  export class PipelineRunner {
28
34
  /**
29
- * Stable ID assigned before start() — safe to use as a Map key in the sidecar
30
- * before the engine-assigned runId becomes available.
35
+ * Stable ID assigned before start() — safe to use as a Map key before
36
+ * the engine-assigned runId becomes available.
31
37
  */
32
38
  readonly instanceId: string;
33
39
 
34
40
  /**
35
- * The runId generated by the engine. Available after the first 'pipeline_start'
36
- * event fires (i.e. effectively immediately after start() is called).
37
- * null until then.
41
+ * The runId generated by the engine. Set when the first `run_start`
42
+ * event arrives on the forwarded event stream. null until then.
38
43
  */
39
44
  private _runId: string | null = null;
40
45
  private _status: PipelineRunnerStatus = 'idle';
41
46
  private _result: Promise<EngineResult> | null = null;
42
47
  private _abortController = new AbortController();
43
- private _handlers = new Set<(event: PipelineEvent) => void>();
44
- private _states: ReadonlyMap<string, TaskState> | null = null;
45
- private _statesMirror = new Map<string, TaskState>();
48
+ private _handlers = new Set<(event: RunEventPayload) => void>();
49
+ /**
50
+ * Wire-shape task mirror, kept in sync with `run_start` / `task_update`
51
+ * events. Exposed through `getTasks()`. Hosts see the same wire
52
+ * projection the editor client sees, so there is exactly one task-state
53
+ * vocabulary across IPC boundaries.
54
+ */
55
+ private _tasks = new Map<string, RunTaskState>();
46
56
 
47
57
  constructor(
48
58
  private readonly config: PipelineConfig,
@@ -79,25 +89,11 @@ export class PipelineRunner {
79
89
  ...this.opts,
80
90
  signal: this._abortController.signal,
81
91
  onEvent: (event) => {
82
- if (event.type === 'pipeline_start') {
83
- this._runId = event.runId;
84
- // Initialize the live mirror with the full initial state snapshot
85
- for (const [id, state] of event.states) {
86
- this._statesMirror.set(id, { ...state });
87
- }
88
- }
89
- if (event.type === 'task_status_change') {
90
- // Keep the mirror up to date so getStates() works during the run
91
- this._statesMirror.set(event.taskId, event.state);
92
- }
93
- if (event.type === 'pipeline_end') {
94
- this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
95
- }
92
+ this._applyEvent(event);
96
93
  for (const h of this._handlers) h(event);
97
94
  },
98
95
  })
99
96
  .then((result) => {
100
- this._states = result.states;
101
97
  if (this._status === 'running') this._status = 'done';
102
98
  return result;
103
99
  })
@@ -109,6 +105,44 @@ export class PipelineRunner {
109
105
  return this._result;
110
106
  }
111
107
 
108
+ private _applyEvent(event: RunEventPayload): void {
109
+ switch (event.type) {
110
+ case 'run_start':
111
+ this._runId = event.runId;
112
+ this._tasks.clear();
113
+ for (const t of event.tasks) this._tasks.set(t.taskId, { ...t });
114
+ return;
115
+ case 'task_update': {
116
+ const prev = this._tasks.get(event.taskId);
117
+ if (!prev) return;
118
+ const pick = <T>(incoming: T | undefined, previous: T): T =>
119
+ incoming !== undefined ? incoming : previous;
120
+ this._tasks.set(event.taskId, {
121
+ ...prev,
122
+ status: event.status,
123
+ startedAt: pick(event.startedAt, prev.startedAt),
124
+ finishedAt: pick(event.finishedAt, prev.finishedAt),
125
+ durationMs: pick(event.durationMs, prev.durationMs),
126
+ exitCode: pick(event.exitCode, prev.exitCode),
127
+ stdout: pick(event.stdout, prev.stdout),
128
+ stderr: pick(event.stderr, prev.stderr),
129
+ stderrPath: pick(event.stderrPath, prev.stderrPath),
130
+ sessionId: pick(event.sessionId, prev.sessionId),
131
+ normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
132
+ resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
133
+ resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
134
+ resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
135
+ });
136
+ return;
137
+ }
138
+ case 'run_end':
139
+ this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
140
+ return;
141
+ default:
142
+ return;
143
+ }
144
+ }
145
+
112
146
  /**
113
147
  * Cancel the running pipeline. Safe to call multiple times or before start().
114
148
  */
@@ -118,39 +152,22 @@ export class PipelineRunner {
118
152
  }
119
153
 
120
154
  /**
121
- * Live snapshot of task states. Available from the first pipeline_start event onward
122
- * (i.e. as soon as start() is called) and remains accessible after the run completes.
123
- * Returns null only if the pipeline has never started.
155
+ * Live snapshot of wire-shape task states. Populated from the first
156
+ * `run_start` event onward. Returns an empty map before the run starts.
124
157
  */
125
- getStates(): ReadonlyMap<string, TaskState> | null {
126
- if (this._states) return snapshotStates(this._states);
127
- if (this._statesMirror.size > 0) return snapshotStates(this._statesMirror);
128
- return null;
158
+ getTasks(): ReadonlyMap<string, RunTaskState> {
159
+ const copy = new Map<string, RunTaskState>();
160
+ for (const [id, t] of this._tasks) copy.set(id, { ...t });
161
+ return copy;
129
162
  }
130
163
 
131
164
  /**
132
- * Subscribe to pipeline/task events. Returns an unsubscribe function.
133
- * Events are emitted synchronously in the engine's event loop, so keep
134
- * handlers non-blocking (e.g. queue to IPC, do not await inside).
165
+ * Subscribe to run events. Returns an unsubscribe function. Events are
166
+ * emitted synchronously in the engine's event loop, so keep handlers
167
+ * non-blocking (e.g. queue to IPC, do not await inside).
135
168
  */
136
- subscribe(handler: (event: PipelineEvent) => void): () => void {
169
+ subscribe(handler: (event: RunEventPayload) => void): () => void {
137
170
  this._handlers.add(handler);
138
171
  return () => this._handlers.delete(handler);
139
172
  }
140
173
  }
141
-
142
- /** Deep-copy a states map so callers cannot mutate SDK internals. */
143
- function snapshotStates(src: ReadonlyMap<string, TaskState>): ReadonlyMap<string, TaskState> {
144
- const copy = new Map<string, TaskState>();
145
- for (const [id, s] of src) {
146
- copy.set(id, {
147
- config: { ...s.config },
148
- trackConfig: { ...s.trackConfig },
149
- status: s.status,
150
- result: s.result ? { ...s.result } : null,
151
- startedAt: s.startedAt,
152
- finishedAt: s.finishedAt,
153
- });
154
- }
155
- return copy;
156
- }
package/src/registry.ts CHANGED
@@ -144,7 +144,6 @@ export function registerPlugin<T extends PluginType>(
144
144
  // first's consumers with no audit trail. A console.warn is cheap,
145
145
  // respects existing callers that rely on 'replaced', and gives ops a
146
146
  // grep-able signal when registrations collide unexpectedly.
147
- // eslint-disable-next-line no-console
148
147
  console.warn(
149
148
  `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} — ` +
150
149
  `check for duplicate plugin packages claiming the same type.`,
@@ -63,7 +63,7 @@ describe('completion default serialization', () => {
63
63
  {
64
64
  id: 'track_a',
65
65
  name: 'Track A',
66
- driver: 'claude-code',
66
+ driver: 'opencode',
67
67
  permissions: { read: true, write: false, execute: false },
68
68
  on_failure: 'skip_downstream',
69
69
  cwd: 'D:/workspace',
@@ -72,7 +72,7 @@ describe('completion default serialization', () => {
72
72
  id: 'task_1',
73
73
  name: 'Task 1',
74
74
  prompt: 'hello',
75
- driver: 'claude-code',
75
+ driver: 'opencode',
76
76
  permissions: { read: true, write: false, execute: false },
77
77
  cwd: 'D:/workspace',
78
78
  completion: { type: 'exit_code', expect: 0 },
@@ -81,7 +81,7 @@ describe('completion default serialization', () => {
81
81
  id: 'task_2',
82
82
  name: 'Task 2',
83
83
  prompt: 'custom',
84
- driver: 'claude-code',
84
+ driver: 'opencode',
85
85
  permissions: { read: true, write: false, execute: false },
86
86
  cwd: 'D:/workspace',
87
87
  completion: { type: 'output_check', check: 'test -f ./done.txt' },
package/src/schema.ts CHANGED
@@ -141,7 +141,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
141
141
  reasoning_effort:
142
142
  rawTask.reasoning_effort ?? rawTrack.reasoning_effort ?? raw.reasoning_effort,
143
143
  permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
144
- driver: rawTask.driver ?? trackDriver ?? 'claude-code',
144
+ driver: rawTask.driver ?? trackDriver ?? 'opencode',
145
145
  timeout: rawTask.timeout,
146
146
  // Middleware: Task-level overrides Track (including [] to disable)
147
147
  middlewares: rawTask.middlewares !== undefined ? rawTask.middlewares : rawTrack.middlewares,
@@ -159,7 +159,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
159
159
  model: rawTrack.model ?? raw.model,
160
160
  reasoning_effort: rawTrack.reasoning_effort ?? raw.reasoning_effort,
161
161
  permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
162
- driver: trackDriver ?? 'claude-code',
162
+ driver: trackDriver ?? 'opencode',
163
163
  cwd: trackCwd,
164
164
  middlewares: rawTrack.middlewares,
165
165
  on_failure: rawTrack.on_failure ?? 'skip_downstream',
@@ -243,7 +243,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
243
243
  const tracks: RawTrackConfig[] = config.tracks.map((track) => {
244
244
  const trackCwdRel =
245
245
  track.cwd && track.cwd !== workDir ? relative(workDir, track.cwd) : undefined;
246
- const effectiveTrackDriver = track.driver ?? config.driver ?? 'claude-code';
246
+ const effectiveTrackDriver = track.driver ?? config.driver ?? 'opencode';
247
247
  const effectiveTrackModel = track.model ?? config.model;
248
248
  const effectiveTrackReasoning = track.reasoning_effort ?? config.reasoning_effort;
249
249
 
@@ -286,7 +286,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
286
286
  ...(track.reasoning_effort && track.reasoning_effort !== config.reasoning_effort
287
287
  ? { reasoning_effort: track.reasoning_effort }
288
288
  : {}),
289
- ...(track.driver && track.driver !== (config.driver ?? 'claude-code')
289
+ ...(track.driver && track.driver !== (config.driver ?? 'opencode')
290
290
  ? { driver: track.driver }
291
291
  : {}),
292
292
  ...(trackCwdRel ? { cwd: trackCwdRel } : {}),
package/src/sdk.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  // ── Core engine ──
7
7
  export { runPipeline, TriggerBlockedError, TriggerTimeoutError } from './engine';
8
- export type { EngineResult, RunPipelineOptions, PipelineEvent } from './engine';
8
+ export type { EngineResult, RunPipelineOptions, RunEventPayload } from './engine';
9
9
 
10
10
  // ── Pipeline runner (multi-pipeline lifecycle management) ──
11
11
  export { PipelineRunner } from './pipeline-runner';
@@ -1,250 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { isAbsolute, relative, dirname, join } from 'node:path';
3
- import type {
4
- DriverPlugin,
5
- DriverCapabilities,
6
- DriverResultMeta,
7
- TaskConfig,
8
- TrackConfig,
9
- DriverContext,
10
- SpawnSpec,
11
- Permissions,
12
- } from '../types';
13
-
14
- // Claude Code CLI reference: https://code.claude.com/docs/en/cli-reference
15
-
16
- const DEFAULT_MODEL = 'sonnet';
17
-
18
- // Claude Code CLI accepts --effort low|medium|high|max. tagma's vocabulary
19
- // is low|medium|high, so low/medium/high pass through unchanged; users who
20
- // want the claude-specific "max" tier can also set it explicitly.
21
- const VALID_EFFORT = new Set(['low', 'medium', 'high', 'max']);
22
-
23
- function resolveModel(): string {
24
- return DEFAULT_MODEL;
25
- }
26
-
27
- function resolveTools(permissions: Permissions): string {
28
- const tools = ['Grep', 'Glob'];
29
- if (permissions.read) tools.push('Read');
30
- if (permissions.write) tools.push('Edit', 'Write');
31
- if (permissions.execute) tools.push('Bash');
32
- return tools.join(',');
33
- }
34
-
35
- // Maps our Permissions to Claude Code's --permission-mode. In print (-p) mode
36
- // Claude needs non-interactive permission handling:
37
- // - `bypassPermissions` skips all checks (required for reliable Bash automation
38
- // under `execute: true`, matches the "full trust" semantics of that tier).
39
- // - `dontAsk` auto-denies anything outside `--allowedTools`, which is exactly
40
- // what we want for read/write tiers: the allowedTools whitelist already
41
- // enumerates what Claude may do, and dontAsk makes violations fail fast
42
- // instead of hanging on a prompt no one can answer in headless mode.
43
- // See: https://code.claude.com/docs/en/permission-modes
44
- function resolvePermissionMode(permissions: Permissions): string {
45
- if (permissions.execute) return 'bypassPermissions';
46
- return 'dontAsk';
47
- }
48
-
49
- // Returns true if `sub` is inside `root` (or equal to it).
50
- function isInside(root: string, sub: string): boolean {
51
- const rel = relative(root, sub);
52
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
53
- }
54
-
55
- // Claude Code requires CLAUDE_CODE_GIT_BASH_PATH on Windows pointing to
56
- // Git Bash (bin\bash.exe under a Git for Windows install). See:
57
- // https://code.claude.com/docs/en/troubleshooting#windows-claude-code-on-windows-requires-git-bash
58
- // The path must use native Windows backslashes — forward slashes are rejected
59
- // by Claude Code's path validation.
60
- function resolveGitBashEnv(): Record<string, string> {
61
- if (process.platform !== 'win32') return {};
62
-
63
- // Respect user-provided value if it points to an actual file. If the user
64
- // set it to a non-existent path, fall through to discovery rather than
65
- // propagating the broken config.
66
- const existing = process.env.CLAUDE_CODE_GIT_BASH_PATH;
67
- if (existing && existsSync(existing)) return {};
68
-
69
- const discovered = discoverGitBash();
70
- return discovered ? { CLAUDE_CODE_GIT_BASH_PATH: discovered } : {};
71
- }
72
-
73
- function discoverGitBash(): string | null {
74
- // Strategy 1: find git.exe in PATH (equivalent to `where.exe git`) and
75
- // walk up looking for bin\bash.exe under a Git install root. Git for
76
- // Windows may expose multiple git.exe locations (cmd\git.exe,
77
- // mingw64\bin\git.exe, mingw64\libexec\git-core\git.exe), so we walk up
78
- // several levels rather than assuming a fixed depth.
79
- const gitExe = findExeInPath('git.exe');
80
- if (gitExe) {
81
- let dir = dirname(gitExe);
82
- for (let depth = 0; depth < 5; depth++) {
83
- const candidate = join(dir, 'bin', 'bash.exe');
84
- if (existsSync(candidate)) return candidate;
85
- const parent = dirname(dir);
86
- if (parent === dir) break;
87
- dir = parent;
88
- }
89
- }
90
-
91
- // Strategy 2: check common Git for Windows install locations.
92
- // Uses %ProgramFiles%/%LOCALAPPDATA%/%USERPROFILE% env vars so it works on
93
- // systems where those aren't mapped to C:\ (e.g. localized Windows).
94
- const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files';
95
- const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
96
- const localAppData = process.env['LOCALAPPDATA'];
97
- const userProfile = process.env['USERPROFILE'];
98
-
99
- const candidates = [
100
- join(programFiles, 'Git', 'bin', 'bash.exe'),
101
- join(programFilesX86, 'Git', 'bin', 'bash.exe'),
102
- // Git for Windows user-level install
103
- localAppData && join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
104
- // Scoop
105
- userProfile && join(userProfile, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
106
- // Chocolatey default
107
- 'C:\\tools\\git\\bin\\bash.exe',
108
- ].filter((p): p is string => Boolean(p));
109
-
110
- for (const c of candidates) {
111
- if (existsSync(c)) return c;
112
- }
113
-
114
- // Strategy 3: scan PATH for any entry containing "git" (e.g. Git's
115
- // mingw64/bin or usr/bin already in PATH), walk up to find bash.exe.
116
- // Catches custom install locations.
117
- const pathEntries = (process.env.PATH ?? '').split(';');
118
- for (const entry of pathEntries) {
119
- if (!/git/i.test(entry)) continue;
120
- const normalized = entry.replace(/\//g, '\\').replace(/\\+$/, '');
121
- const parts = normalized.split('\\');
122
- for (let depth = 1; depth <= 4; depth++) {
123
- const root = parts.slice(0, parts.length - depth).join('\\');
124
- if (!root) continue;
125
- const candidate = root + '\\bin\\bash.exe';
126
- if (existsSync(candidate)) return candidate;
127
- }
128
- }
129
-
130
- return null;
131
- }
132
-
133
- function findExeInPath(exe: string): string | null {
134
- const pathDirs = (process.env.PATH ?? '').split(';');
135
- for (const dir of pathDirs) {
136
- if (!dir) continue;
137
- const full = join(dir, exe);
138
- if (existsSync(full)) return full;
139
- }
140
- return null;
141
- }
142
-
143
- export const ClaudeCodeDriver: DriverPlugin = {
144
- name: 'claude-code',
145
-
146
- capabilities: {
147
- sessionResume: true,
148
- systemPrompt: true,
149
- outputFormat: true,
150
- } satisfies DriverCapabilities,
151
-
152
- resolveModel,
153
- resolveTools,
154
-
155
- async buildCommand(task: TaskConfig, track: TrackConfig, ctx: DriverContext): Promise<SpawnSpec> {
156
- const permissions = task.permissions ?? track.permissions!;
157
- const model = task.model ?? track.model ?? DEFAULT_MODEL;
158
- // SDK schema layer already resolved task → track → pipeline inheritance.
159
- // Drop unknown effort values so a typo can't break `claude -p` startup;
160
- // validateRaw / the UI should prevent this from reaching us in practice.
161
- const rawEffort = task.reasoning_effort ?? track.reasoning_effort;
162
- const effort = rawEffort && VALID_EFFORT.has(rawEffort) ? rawEffort : null;
163
- const tools = resolveTools(permissions);
164
- const permissionMode = resolvePermissionMode(permissions);
165
-
166
- // Pass the prompt via stdin instead of as a -p argument value. On Windows,
167
- // multi-line strings in CLI arguments break cmd.exe argument parsing when
168
- // the executable is a .cmd wrapper — newlines cause all subsequent flags
169
- // (--output-format, --model, etc.) to be silently dropped.
170
- const stdin = task.prompt!;
171
-
172
- const args: string[] = [
173
- 'claude',
174
- '-p', // no value — prompt is piped via stdin
175
- '--model',
176
- model,
177
- '--allowedTools',
178
- tools,
179
- '--permission-mode',
180
- permissionMode,
181
- '--output-format',
182
- 'json',
183
- // NOTE: do NOT use --verbose here. It changes stdout from a single JSON
184
- // result object to a JSON event-stream array, breaking parseResult's
185
- // session_id extraction (needed for continue_from) and normalizedOutput.
186
- // The engine already captures stdout/stderr for pipeline logs.
187
- // Pin to project+local settings only; don't inherit arbitrary user-level
188
- // config (hooks, MCP servers, etc.) into pipeline automation.
189
- '--setting-sources',
190
- 'project,local',
191
- ];
192
-
193
- if (effort) {
194
- args.push('--effort', effort);
195
- }
196
-
197
- // If the task runs in a subdirectory of the project, grant read/edit
198
- // access to the project root via --add-dir so Claude can still see
199
- // shared files (configs, types, etc.) outside task.cwd.
200
- const effectiveCwd = task.cwd ?? ctx.workDir;
201
- if (effectiveCwd !== ctx.workDir && isInside(ctx.workDir, effectiveCwd)) {
202
- args.push('--add-dir', ctx.workDir);
203
- }
204
-
205
- // Native session resume
206
- if (task.continue_from) {
207
- const sessionId = ctx.sessionMap.get(task.continue_from);
208
- if (sessionId) {
209
- args.push('--resume', sessionId);
210
- }
211
- }
212
-
213
- // --append-system-prompt MUST be last: its value may contain newlines,
214
- // and on Windows cmd.exe can silently drop any flags that follow a
215
- // newline-containing argument.
216
- const profile = task.agent_profile ?? track.agent_profile;
217
- if (profile) {
218
- args.push('--append-system-prompt', profile);
219
- }
220
-
221
- return { args, cwd: effectiveCwd, env: resolveGitBashEnv(), stdin };
222
- },
223
-
224
- parseResult(stdout: string): DriverResultMeta {
225
- try {
226
- let json = JSON.parse(stdout);
227
-
228
- // --verbose produces a JSON array of events; extract the final "result"
229
- // event so session_id and normalizedOutput are correctly populated.
230
- if (Array.isArray(json)) {
231
- const resultEvent = json.findLast((e: Record<string, unknown>) => e.type === 'result');
232
- if (!resultEvent) return { normalizedOutput: stdout };
233
- json = resultEvent;
234
- }
235
-
236
- // Extract canonical text: strip JSON envelope so downstream drivers
237
- // get the actual AI response, not metadata
238
- const normalizedOutput = json.result ?? json.text ?? json.content ?? stdout;
239
- return {
240
- sessionId: json.session_id,
241
- normalizedOutput:
242
- typeof normalizedOutput === 'string'
243
- ? normalizedOutput
244
- : JSON.stringify(normalizedOutput),
245
- };
246
- } catch {
247
- return { normalizedOutput: stdout };
248
- }
249
- },
250
- };