@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.
@@ -0,0 +1,204 @@
1
+ import type {
2
+ DriverPlugin,
3
+ DriverCapabilities,
4
+ DriverResultMeta,
5
+ TaskConfig,
6
+ TrackConfig,
7
+ DriverContext,
8
+ SpawnSpec,
9
+ } from '../types';
10
+
11
+ const DEFAULT_MODEL = 'opencode/big-pickle';
12
+
13
+ // NOTE on Windows multi-line prompts: `opencode` resolves to `opencode.cmd`,
14
+ // an npm-generated batch wrapper. cmd.exe silently truncates argv elements
15
+ // at the first newline, so a multi-line prompt reaches the model as only
16
+ // its first line. The SDK's runner auto-unwraps npm .cmd shims into direct
17
+ // `node <js-entry>` invocations so newlines survive, and this driver can
18
+ // keep using the bare `opencode` name on every platform.
19
+
20
+ // tagma uses a provider-neutral reasoning_effort vocabulary (low|medium|high)
21
+ // but opencode's `--variant` is provider-specific (e.g. high|max|minimal).
22
+ // Map the tagma values to the closest opencode variant:
23
+ // low → minimal (least thinking)
24
+ // medium → <no flag, provider default>
25
+ // high → high (most thinking)
26
+ // Unknown values pass through unchanged so users who target a specific
27
+ // opencode variant (e.g. "max") still work.
28
+ const EFFORT_TO_VARIANT: Record<string, string | null> = {
29
+ low: 'minimal',
30
+ medium: null,
31
+ high: 'high',
32
+ };
33
+
34
+ export const OpenCodeDriver: DriverPlugin = {
35
+ name: 'opencode',
36
+
37
+ capabilities: {
38
+ sessionResume: true, // supports --session
39
+ systemPrompt: false, // no --system-prompt flag; prepend to prompt instead
40
+ outputFormat: true, // supports --format json
41
+ } satisfies DriverCapabilities,
42
+
43
+ resolveModel(): string {
44
+ return DEFAULT_MODEL;
45
+ },
46
+
47
+ async buildCommand(task: TaskConfig, track: TrackConfig, ctx: DriverContext): Promise<SpawnSpec> {
48
+ const model = task.model ?? track.model ?? DEFAULT_MODEL;
49
+ // Resolve reasoning_effort → opencode --variant. SDK schema layer already
50
+ // resolved task → track → pipeline inheritance, so we only need to read
51
+ // task.reasoning_effort here.
52
+ const rawEffort = task.reasoning_effort ?? track.reasoning_effort;
53
+ const variant = rawEffort
54
+ ? rawEffort in EFFORT_TO_VARIANT
55
+ ? EFFORT_TO_VARIANT[rawEffort]
56
+ : rawEffort
57
+ : null;
58
+
59
+ let prompt = task.prompt!;
60
+
61
+ // agent_profile has no dedicated flag; prepend to prompt
62
+ const profile = task.agent_profile ?? track.agent_profile;
63
+ if (profile) {
64
+ prompt = `[Role]\n${profile}\n\n[Task]\n${prompt}`;
65
+ }
66
+
67
+ // continue_from: prefer session resume, fall back to text injection
68
+ let sessionId: string | null = null;
69
+ if (task.continue_from) {
70
+ sessionId = ctx.sessionMap.get(task.continue_from) ?? null;
71
+ if (!sessionId) {
72
+ // no session — degrade to text context passthrough
73
+ let prev: string | null = null;
74
+ if (ctx.normalizedMap.has(task.continue_from)) {
75
+ prev = ctx.normalizedMap.get(task.continue_from)!;
76
+ }
77
+ if (prev !== null) {
78
+ prompt = `[Previous Output]\n${prev}\n\n[Current Task]\n${prompt}`;
79
+ }
80
+ }
81
+ }
82
+
83
+ // opencode run does not support stdin (no `-` placeholder like codex exec).
84
+ // Prompt is always a positional argument. Flags must be declared before `--`;
85
+ // the prompt follows after so that leading `--flag` content cannot be
86
+ // misread by opencode's argument parser (flag-injection mitigation).
87
+ // Shell-level injection is already prevented by Bun.spawn's direct argv array.
88
+ // Windows cmd.exe argv truncation on the `.cmd` wrapper is handled by the
89
+ // SDK runner's shim unwrapping — see note at the top of this file.
90
+ const args: string[] = [
91
+ 'opencode',
92
+ 'run',
93
+ '--model',
94
+ model,
95
+ '--format',
96
+ 'json', // JSON output for parseResult
97
+ ];
98
+
99
+ // `--variant` must precede `--` like every other flag. opencode rejects
100
+ // unknown variant names with a clear error, so we don't pre-validate.
101
+ if (variant) {
102
+ args.push('--variant', variant);
103
+ }
104
+
105
+ // session resume (must appear before --)
106
+ if (sessionId) {
107
+ args.push('--session', sessionId);
108
+ }
109
+
110
+ // `--` (POSIX end-of-options) isolates prompt from flag parsing
111
+ args.push('--', prompt);
112
+
113
+ return { args, cwd: task.cwd ?? ctx.workDir };
114
+ },
115
+
116
+ parseResult(stdout: string): DriverResultMeta {
117
+ // opencode --format json emits NDJSON — one JSON object per line
118
+ // (step_start / text / step_finish / …). The previous single
119
+ // `JSON.parse(stdout)` always threw on this shape and fell through to
120
+ // the catch, returning sessionId:null and losing session resume.
121
+ // Walk line-by-line, pick up the first sessionID we see, concatenate
122
+ // any text-type parts into normalizedOutput, and bail early on error
123
+ // payloads.
124
+ const lines = stdout.split(/\r?\n/);
125
+ let sessionId: string | undefined;
126
+ const textParts: string[] = [];
127
+ let sawAnyJson = false;
128
+ let errorReason: string | null = null;
129
+
130
+ for (const raw of lines) {
131
+ const line = raw.trim();
132
+ if (!line) continue;
133
+ let json: Record<string, unknown>;
134
+ try {
135
+ json = JSON.parse(line) as Record<string, unknown>;
136
+ } catch {
137
+ continue; // tolerate interleaved non-JSON noise
138
+ }
139
+ sawAnyJson = true;
140
+
141
+ // M12: opencode sometimes emits {type:"error", error:{...}} with
142
+ // exit 0 for transient API failures. Force-fail so downstream
143
+ // skip_downstream / stop_all kicks in.
144
+ if (json.type === 'error') {
145
+ const err = json.error as { message?: unknown } | string | undefined;
146
+ const msg =
147
+ typeof err === 'object' && err !== null && typeof err.message === 'string'
148
+ ? err.message
149
+ : typeof err === 'string'
150
+ ? err
151
+ : null;
152
+ errorReason = msg
153
+ ? `opencode reported error: ${msg}`
154
+ : 'opencode emitted an error JSON payload';
155
+ // D21: stop at the first error. Continuing meant subsequent text
156
+ // lines got accumulated into `textParts` only to be discarded by
157
+ // the error-return below, and a later `{type:"error"}` would
158
+ // silently overwrite the original cause — operators then debugged
159
+ // a downstream symptom while the root-cause line scrolled past.
160
+ break;
161
+ }
162
+
163
+ // Session id — opencode uses `sessionID` (camelCase with capital D).
164
+ // Keep `session_id` / `sessionId` as fallbacks for forward/backward
165
+ // compatibility with other shapes.
166
+ if (!sessionId) {
167
+ const sid =
168
+ (json.sessionID as string | undefined) ??
169
+ (json.session_id as string | undefined) ??
170
+ (json.sessionId as string | undefined) ??
171
+ null;
172
+ if (typeof sid === 'string' && sid.length > 0) sessionId = sid;
173
+ }
174
+
175
+ // Extract human-readable text from text-type parts.
176
+ if (json.type === 'text') {
177
+ const part = json.part as { text?: unknown } | undefined;
178
+ if (part && typeof part.text === 'string') {
179
+ textParts.push(part.text);
180
+ }
181
+ } else if (typeof json.result === 'string') {
182
+ textParts.push(json.result);
183
+ } else if (typeof json.content === 'string') {
184
+ textParts.push(json.content);
185
+ }
186
+ }
187
+
188
+ if (errorReason) {
189
+ return { forceFailure: true, forceFailureReason: errorReason };
190
+ }
191
+
192
+ // If nothing parsed as JSON, treat stdout as plain text.
193
+ const normalizedOutput = !sawAnyJson
194
+ ? stdout
195
+ : textParts.length > 0
196
+ ? textParts.join('\n')
197
+ : stdout;
198
+
199
+ return {
200
+ sessionId,
201
+ normalizedOutput,
202
+ };
203
+ },
204
+ };
package/src/engine.ts CHANGED
@@ -14,6 +14,10 @@ import type {
14
14
  DriverContext,
15
15
  OnFailure,
16
16
  PromptDocument,
17
+ Permissions,
18
+ AbortReason,
19
+ RunEventPayload,
20
+ RunTaskState,
17
21
  } from './types';
18
22
  import { buildDag, type Dag } from './dag';
19
23
  import { getHandler, hasHandler, loadPlugins } from './registry';
@@ -30,7 +34,7 @@ import {
30
34
  type TrackInfo,
31
35
  type TaskInfo,
32
36
  } from './hooks';
33
- import { Logger, tailLines, clip, type LogLevel } from './logger';
37
+ import { Logger, tailLines, clip } from './logger';
34
38
  import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
35
39
 
36
40
  // ═══ A7: Typed trigger errors ═══
@@ -61,7 +65,7 @@ function preflight(config: PipelineConfig, dag: Dag): void {
61
65
  for (const [, node] of dag.nodes) {
62
66
  const task = node.task;
63
67
  const track = node.track;
64
- const driverName = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
68
+ const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
65
69
 
66
70
  // Pure command tasks don't use a driver — skip driver registration check.
67
71
  const isCommandOnly = task.command && !task.prompt;
@@ -101,7 +105,7 @@ function preflight(config: PipelineConfig, dag: Dag): void {
101
105
  // OR in-memory text injection through normalizedMap
102
106
  // (when the upstream driver implements parseResult and returns normalizedOutput).
103
107
  const upstreamDriverName =
104
- upstream.task.driver ?? upstream.track.driver ?? config.driver ?? 'claude-code';
108
+ upstream.task.driver ?? upstream.track.driver ?? config.driver ?? 'opencode';
105
109
  const upstreamDriver = hasHandler('drivers', upstreamDriverName)
106
110
  ? getHandler<DriverPlugin>('drivers', upstreamDriverName)
107
111
  : null;
@@ -144,37 +148,52 @@ export interface EngineResult {
144
148
  }
145
149
 
146
150
  // ═══ Pipeline Events ═══
151
+ //
152
+ // The engine emits RunEventPayload values (defined in @tagma/types) via
153
+ // `onEvent`. Every payload carries `runId`; the editor server stamps a
154
+ // per-run `seq` before broadcasting. There is one event vocabulary
155
+ // end-to-end — no server-side translation layer.
147
156
 
148
- export type PipelineEvent =
149
- | {
150
- readonly type: 'task_status_change';
151
- readonly taskId: string;
152
- readonly status: TaskStatus;
153
- readonly prevStatus: TaskStatus;
154
- readonly runId: string;
155
- readonly state: TaskState;
156
- }
157
- | {
158
- readonly type: 'pipeline_start';
159
- readonly runId: string;
160
- readonly states: ReadonlyMap<string, TaskState>;
161
- }
162
- | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean }
163
- /**
164
- * Fine-grained log line emitted alongside every write to pipeline.log.
165
- * Consumers use this to stream the full run process into UIs without
166
- * tailing the log file. `taskId` is non-null for task-scoped lines and
167
- * null for pipeline-wide messages (e.g. configuration dumps, DAG
168
- * topology, pipeline start/end).
169
- */
170
- | {
171
- readonly type: 'task_log';
172
- readonly runId: string;
173
- readonly taskId: string | null;
174
- readonly level: LogLevel;
175
- readonly timestamp: string;
176
- readonly text: string;
177
- };
157
+ // Re-export so SDK consumers can import the event type without reaching
158
+ // into @tagma/types directly.
159
+ export type { RunEventPayload } from './types';
160
+
161
+ // ═══ Helpers ═══
162
+
163
+ /**
164
+ * Project the engine's internal TaskState onto the wire RunTaskState
165
+ * shape. `logs` / `totalLogCount` default to empty — they are populated
166
+ * on the server side from streamed `task_log` events, not from state.
167
+ */
168
+ function toRunTaskState(
169
+ taskId: string,
170
+ trackId: string,
171
+ taskName: string,
172
+ state: TaskState,
173
+ ): RunTaskState {
174
+ const result = state.result;
175
+ const cfg = state.config;
176
+ return {
177
+ taskId,
178
+ trackId,
179
+ taskName,
180
+ status: state.status,
181
+ startedAt: state.startedAt,
182
+ finishedAt: state.finishedAt,
183
+ durationMs: result?.durationMs ?? null,
184
+ exitCode: result?.exitCode ?? null,
185
+ stdout: result?.stdout ?? '',
186
+ stderr: result?.stderr ?? '',
187
+ stderrPath: result?.stderrPath ?? null,
188
+ sessionId: result?.sessionId ?? null,
189
+ normalizedOutput: result?.normalizedOutput ?? null,
190
+ resolvedDriver: cfg.driver ?? null,
191
+ resolvedModel: cfg.model ?? null,
192
+ resolvedPermissions: (cfg.permissions as Permissions | undefined) ?? null,
193
+ logs: [],
194
+ totalLogCount: 0,
195
+ };
196
+ }
178
197
 
179
198
  export interface RunPipelineOptions {
180
199
  readonly approvalGateway?: ApprovalGateway;
@@ -198,7 +217,7 @@ export interface RunPipelineOptions {
198
217
  * Called on every pipeline/task status transition.
199
218
  * Use for real-time UI updates (e.g. updating a visual workflow graph).
200
219
  */
201
- readonly onEvent?: (event: PipelineEvent) => void;
220
+ readonly onEvent?: (event: RunEventPayload) => void;
202
221
  /**
203
222
  * Skip the engine's built-in `loadPlugins(config.plugins)` call.
204
223
  * Use this when the host has already pre-loaded plugins from a custom
@@ -260,7 +279,7 @@ export async function runPipeline(
260
279
  // File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
261
280
  log.section('Pipeline configuration');
262
281
  log.quiet(`name: ${config.name}`);
263
- log.quiet(`driver: ${config.driver ?? '(default: claude-code)'}`);
282
+ log.quiet(`driver: ${config.driver ?? '(default: opencode)'}`);
264
283
  log.quiet(`timeout: ${config.timeout ?? '(none)'}`);
265
284
  log.quiet(`tracks: ${config.tracks.length}`);
266
285
  log.quiet(`tasks (total): ${dag.nodes.size}`);
@@ -290,7 +309,10 @@ export async function runPipeline(
290
309
  });
291
310
  }
292
311
 
293
- // Pipeline start hook (gate)
312
+ // Pipeline start hook (gate). Runs BEFORE the engine emits run_start so
313
+ // a blocked pipeline produces zero wire events (the server treats the
314
+ // thrown error as run_error). Hosts get a rich error message; nothing
315
+ // is ever half-broadcast.
294
316
  const startHook = await executeHook(
295
317
  config.hooks,
296
318
  'pipeline_start',
@@ -305,7 +327,6 @@ export async function runPipeline(
305
327
  buildPipelineErrorContext(pipelineInfo, 'pipeline_blocked', 'pipeline_blocked'),
306
328
  workDir,
307
329
  );
308
- // All tasks stay idle — pipeline never started
309
330
  return {
310
331
  success: false,
311
332
  runId,
@@ -322,41 +343,51 @@ export async function runPipeline(
322
343
  };
323
344
  }
324
345
 
325
- // Pipeline approved — transition all tasks to waiting
346
+ // Pipeline approved — transition all tasks to waiting.
326
347
  for (const [, state] of states) {
327
348
  state.status = 'waiting';
328
349
  }
329
- // Include a full states snapshot so listeners can initialize their mirrors without missing events
330
- const statesSnapshot: ReadonlyMap<string, TaskState> = new Map(
331
- [...states.entries()].map(([id, s]) => [id, { ...s }]),
332
- );
333
- options.onEvent?.({ type: 'pipeline_start', runId, states: statesSnapshot });
350
+ // Emit run_start with a wire-shape snapshot so SSE subscribers can
351
+ // initialize their task maps on the same event stream that carries
352
+ // updates. No separate "server pre-broadcasts run_start" ceremony —
353
+ // the engine owns the lifecycle boundary.
354
+ const runStartTasks: RunTaskState[] = [];
355
+ for (const [id, node] of dag.nodes) {
356
+ const s = states.get(id)!;
357
+ runStartTasks.push(toRunTaskState(id, node.track.id, node.task.name ?? id, s));
358
+ }
359
+ emit({ type: 'run_start', runId, tasks: runStartTasks });
334
360
 
335
361
  const sessionMap = new Map<string, string>();
336
362
  const normalizedMap = new Map<string, string>();
337
363
 
338
- // Pipeline timeout
364
+ // Pipeline timeout + abort reason tracking.
365
+ //
366
+ // `abortReason` replaces the previous `pipelineAborted: boolean`: it
367
+ // carries the concrete cause (timeout / stop_all / external) through
368
+ // to run_end and the pipeline_error hook so downstream consumers can
369
+ // distinguish them without scraping message strings.
339
370
  const pipelineTimeoutMs = config.timeout ? parseDuration(config.timeout) : 0;
340
- let pipelineAborted = false;
371
+ let abortReason: AbortReason | null = null;
341
372
  const abortController = new AbortController();
342
373
  let pipelineTimer: ReturnType<typeof setTimeout> | null = null;
343
374
 
344
375
  if (pipelineTimeoutMs > 0) {
345
376
  pipelineTimer = setTimeout(() => {
346
- pipelineAborted = true;
377
+ if (abortReason === null) abortReason = 'timeout';
347
378
  abortController.abort();
348
379
  }, pipelineTimeoutMs);
349
380
  }
350
381
 
351
- // When the pipeline is aborted (timeout, external shutdown), drain all
352
- // pending approvals so waiting triggers unblock immediately.
382
+ // When the pipeline is aborted (timeout, stop_all, external), drain
383
+ // all pending approvals so waiting triggers unblock immediately.
353
384
  abortController.signal.addEventListener('abort', () => {
354
385
  approvalGateway.abortAll('pipeline aborted');
355
386
  });
356
387
 
357
388
  // Wire external cancel signal into the internal abort controller.
358
389
  const externalAbortHandler = () => {
359
- pipelineAborted = true;
390
+ if (abortReason === null) abortReason = 'external';
360
391
  abortController.abort();
361
392
  };
362
393
  if (options.signal) {
@@ -367,9 +398,45 @@ export async function runPipeline(
367
398
  }
368
399
  }
369
400
 
401
+ // Bridge approval gateway events onto the wire stream so hosts (editor
402
+ // server, CLI adapters) see approvals on the same channel as task
403
+ // updates. The server no longer needs its own gateway subscription.
404
+ const unsubscribeApprovals = approvalGateway.subscribe((ev) => {
405
+ if (ev.type === 'requested') {
406
+ emit({
407
+ type: 'approval_request',
408
+ runId,
409
+ request: {
410
+ id: ev.request.id,
411
+ taskId: ev.request.taskId,
412
+ trackId: ev.request.trackId,
413
+ message: ev.request.message,
414
+ createdAt: ev.request.createdAt,
415
+ timeoutMs: ev.request.timeoutMs,
416
+ metadata: ev.request.metadata,
417
+ },
418
+ });
419
+ return;
420
+ }
421
+ if (ev.type === 'resolved' || ev.type === 'expired' || ev.type === 'aborted') {
422
+ const outcome =
423
+ ev.type === 'resolved'
424
+ ? ev.decision.outcome
425
+ : ev.type === 'expired'
426
+ ? 'timeout'
427
+ : 'aborted';
428
+ emit({
429
+ type: 'approval_resolved',
430
+ runId,
431
+ requestId: ev.request.id,
432
+ outcome,
433
+ });
434
+ }
435
+ });
436
+
370
437
  // ── Helpers ──
371
438
 
372
- function emit(event: PipelineEvent): void {
439
+ function emit(event: RunEventPayload): void {
373
440
  options.onEvent?.(event);
374
441
  }
375
442
 
@@ -380,24 +447,26 @@ export async function runPipeline(
380
447
  // skipped and then having their in-flight processTask promise overwrite
381
448
  // that with success/failed, producing an invalid double transition.
382
449
  if (isTerminal(state.status)) return;
383
- const prevStatus = state.status;
384
450
  state.status = newStatus;
385
- // Snapshot state at emit time — result and finishedAt must be set before calling this for terminal statuses
386
- const snapshot: TaskState = {
387
- config: state.config,
388
- trackConfig: state.trackConfig,
389
- status: state.status,
390
- result: state.result,
391
- startedAt: state.startedAt,
392
- finishedAt: state.finishedAt,
393
- };
451
+ const result = state.result;
452
+ const cfg = state.config;
394
453
  emit({
395
- type: 'task_status_change',
454
+ type: 'task_update',
455
+ runId,
396
456
  taskId,
397
457
  status: newStatus,
398
- prevStatus,
399
- runId,
400
- state: snapshot,
458
+ startedAt: state.startedAt ?? undefined,
459
+ finishedAt: state.finishedAt ?? undefined,
460
+ durationMs: result?.durationMs,
461
+ exitCode: result?.exitCode,
462
+ stdout: result?.stdout,
463
+ stderr: result?.stderr,
464
+ stderrPath: result?.stderrPath ?? null,
465
+ sessionId: result?.sessionId ?? null,
466
+ normalizedOutput: result?.normalizedOutput ?? null,
467
+ resolvedDriver: cfg.driver ?? null,
468
+ resolvedModel: cfg.model ?? null,
469
+ resolvedPermissions: (cfg.permissions as Permissions | undefined) ?? null,
401
470
  });
402
471
  }
403
472
 
@@ -435,7 +504,7 @@ export async function runPipeline(
435
504
  * should a completed running task try to overwrite the skipped state.
436
505
  */
437
506
  function applyStopAll(_failedTrackId: string): void {
438
- pipelineAborted = true;
507
+ if (abortReason === null) abortReason = 'stop_all';
439
508
  abortController.abort();
440
509
  for (const [id, state] of states) {
441
510
  if (state.status === 'waiting') {
@@ -559,7 +628,7 @@ export async function runPipeline(
559
628
  // If pipeline was aborted while we were still waiting for the trigger,
560
629
  // this task never entered running state → skipped, not timeout.
561
630
  state.finishedAt = nowISO();
562
- if (pipelineAborted) {
631
+ if (abortReason !== null) {
563
632
  setTaskStatus(taskId, 'skipped');
564
633
  } else if (err instanceof TriggerBlockedError) {
565
634
  setTaskStatus(taskId, 'blocked'); // user/policy rejection
@@ -618,7 +687,7 @@ export async function runPipeline(
618
687
  }
619
688
 
620
689
  // 4. Mark running — set startedAt before emitting so subscribers see a
621
- // complete snapshot (startedAt non-null) in the task_status_change event.
690
+ // complete task_update (startedAt non-null) on the status transition.
622
691
  state.startedAt = nowISO();
623
692
  setTaskStatus(taskId, 'running');
624
693
  log.info(
@@ -627,7 +696,7 @@ export async function runPipeline(
627
696
  );
628
697
 
629
698
  // File-only: resolved config for this task
630
- const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
699
+ const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'opencode';
631
700
  const resolvedModel = task.model ?? track.model ?? config.model ?? '(default)';
632
701
  const resolvedPerms = task.permissions ?? track.permissions ?? '(default)';
633
702
  const resolvedCwd = task.cwd ?? track.cwd ?? workDir;
@@ -654,7 +723,7 @@ export async function runPipeline(
654
723
  result = await runCommand(task.command, task.cwd ?? workDir, runOpts);
655
724
  } else {
656
725
  // AI task: apply middleware chain against a structured PromptDocument.
657
- const driverName = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
726
+ const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
658
727
  const driver = getHandler<DriverPlugin>('drivers', driverName);
659
728
 
660
729
  const originalLen = task.prompt!.length;
@@ -936,7 +1005,7 @@ export async function runPipeline(
936
1005
  const running = new Map<string, Promise<void>>();
937
1006
 
938
1007
  try {
939
- while (!pipelineAborted) {
1008
+ while (abortReason === null) {
940
1009
  // Launch every task whose deps are all terminal and that isn't already in-flight
941
1010
  for (const [id, state] of states) {
942
1011
  if (state.status !== 'waiting' || running.has(id)) continue;
@@ -962,7 +1031,7 @@ export async function runPipeline(
962
1031
  }
963
1032
  }
964
1033
 
965
- if (pipelineAborted) {
1034
+ if (abortReason !== null) {
966
1035
  // Wait for in-flight tasks to honour the abort signal before marking states.
967
1036
  if (running.size > 0) await Promise.allSettled(running.values());
968
1037
  for (const [id, state] of states) {
@@ -986,6 +1055,9 @@ export async function runPipeline(
986
1055
  if (approvalGateway.pending().length > 0) {
987
1056
  approvalGateway.abortAll('pipeline finished');
988
1057
  }
1058
+ // Detach gateway → onEvent bridge so a long-lived gateway (host-supplied)
1059
+ // doesn't keep firing into a dead run.
1060
+ unsubscribeApprovals();
989
1061
  }
990
1062
 
991
1063
  // ── Summary ──
@@ -1014,11 +1086,17 @@ export async function runPipeline(
1014
1086
  const finishedAt = nowISO();
1015
1087
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
1016
1088
 
1017
- if (pipelineAborted) {
1089
+ if (abortReason !== null) {
1090
+ const reasonText =
1091
+ abortReason === 'timeout'
1092
+ ? 'Pipeline timeout exceeded'
1093
+ : abortReason === 'stop_all'
1094
+ ? 'Pipeline stopped (on_failure: stop_all)'
1095
+ : 'Pipeline aborted by host';
1018
1096
  await executeHook(
1019
1097
  config.hooks,
1020
1098
  'pipeline_error',
1021
- buildPipelineErrorContext(pipelineInfo, 'Pipeline timeout exceeded'),
1099
+ buildPipelineErrorContext(pipelineInfo, reasonText, undefined, abortReason),
1022
1100
  workDir,
1023
1101
  );
1024
1102
  } else {
@@ -1034,10 +1112,15 @@ export async function runPipeline(
1034
1112
  }
1035
1113
 
1036
1114
  const allSuccess =
1037
- !pipelineAborted && summary.failed === 0 && summary.timeout === 0 && summary.blocked === 0;
1115
+ abortReason === null &&
1116
+ summary.failed === 0 &&
1117
+ summary.timeout === 0 &&
1118
+ summary.blocked === 0;
1038
1119
 
1039
1120
  log.section('Pipeline summary');
1040
- log.quiet(`status: ${pipelineAborted ? 'aborted (timeout)' : 'completed'}`);
1121
+ log.quiet(
1122
+ `status: ${abortReason !== null ? `aborted (${abortReason})` : 'completed'}`,
1123
+ );
1041
1124
  log.quiet(`duration: ${(durationMs / 1000).toFixed(1)}s`);
1042
1125
  log.quiet(
1043
1126
  `counts: total=${summary.total} success=${summary.success} ` +
@@ -1061,7 +1144,7 @@ export async function runPipeline(
1061
1144
  log.info('[pipeline]', `Duration: ${(durationMs / 1000).toFixed(1)}s`);
1062
1145
  log.info('[pipeline]', `Log: ${log.path}`);
1063
1146
 
1064
- emit({ type: 'pipeline_end', runId, success: allSuccess });
1147
+ emit({ type: 'run_end', runId, success: allSuccess, abortReason });
1065
1148
  return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
1066
1149
  } finally {
1067
1150
  // Close the persistent log file handle before pruning.
package/src/hooks.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HooksConfig, HookCommand } from './types';
1
+ import type { HooksConfig, HookCommand, AbortReason } from './types';
2
2
  import { shellArgs } from './utils';
3
3
 
4
4
  type HookEvent =
@@ -182,6 +182,12 @@ export function buildPipelineErrorContext(
182
182
  pipeline: PipelineInfo,
183
183
  error: string,
184
184
  eventType?: string,
185
+ abortReason?: AbortReason,
185
186
  ) {
186
- return { event: eventType ?? 'pipeline_error', pipeline, error };
187
+ return {
188
+ event: eventType ?? 'pipeline_error',
189
+ pipeline,
190
+ error,
191
+ ...(abortReason !== undefined ? { abort_reason: abortReason } : {}),
192
+ };
187
193
  }