@tagma/sdk 0.6.0 → 0.6.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.
package/README.md CHANGED
@@ -1,573 +1,573 @@
1
- # @tagma/sdk
2
-
3
- > ## ⚠️ Bun-only runtime
4
- >
5
- > ```bash
6
- > bun add @tagma/sdk
7
- > ```
8
- >
9
- > This package is published as pre-built JavaScript (`dist/`), but the runtime
10
- > uses Bun-only APIs (`Bun.spawn`, `Bun.file`, `Bun.serve`). It will install
11
- > under `npm` / `yarn` / `pnpm` without error, but **crash at runtime on Node**
12
- > the first time a pipeline spawns a task. Get Bun at <https://bun.sh>.
13
- >
14
- > _(The `npm i @tagma/sdk` line in the npmjs.com sidebar is auto-generated by
15
- > the npm registry website and cannot be removed — please ignore it and use
16
- > the command above.)_
17
-
18
- A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (OpenCode, Codex, Claude Code) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
19
-
20
- ## Install
21
-
22
- **Requires Bun ≥ 1.3.** Node/npm are not supported.
23
-
24
- ```bash
25
- bun add @tagma/sdk
26
- ```
27
-
28
- ## Quick Start
29
-
30
- **1. Define a pipeline** (`pipeline.yaml`)
31
-
32
- ```yaml
33
- pipeline:
34
- name: build-and-test
35
- tracks:
36
- - id: backend
37
- name: Backend
38
- driver: opencode
39
- permissions: { read: true, write: true, execute: false }
40
- tasks:
41
- - id: implement
42
- name: Implement feature
43
- prompt: 'Add a /health endpoint to src/server.ts'
44
- - id: test
45
- name: Run tests
46
- command: 'bun test'
47
- depends_on: [implement]
48
- ```
49
-
50
- **2. Run it programmatically**
51
-
52
- ```ts
53
- import { bootstrapBuiltins, loadPipeline, runPipeline, InMemoryApprovalGateway } from '@tagma/sdk';
54
-
55
- // Register built-in drivers, triggers, completions
56
- bootstrapBuiltins();
57
-
58
- const yaml = await Bun.file('pipeline.yaml').text();
59
- const config = await loadPipeline(yaml, process.cwd());
60
-
61
- const result = await runPipeline(config, process.cwd());
62
- console.log(result.success ? 'Done' : 'Failed');
63
- ```
64
-
65
- ## Features
66
-
67
- - **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
68
- - **Driver plugins** -- built-in `opencode` driver; install `@tagma/driver-codex` or `@tagma/driver-claude-code` for other agents
69
- - **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
70
- - **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
71
- - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
72
- - **Middleware** -- enrich prompts before execution (e.g. inject static context)
73
- - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
74
- - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
75
-
76
- ## Pipeline YAML Reference
77
-
78
- ### Full Structure
79
-
80
- ```yaml
81
- pipeline:
82
- name: my-pipeline
83
- driver: opencode
84
- timeout: 30m
85
- plugins:
86
- - '@tagma/driver-codex'
87
- - '@tagma/driver-claude-code'
88
- hooks:
89
- pipeline_start: 'echo starting'
90
- task_start: 'echo task begin'
91
- task_success: 'echo task ok'
92
- task_failure: 'notify-slack.sh'
93
- pipeline_complete: 'echo done'
94
- pipeline_error: 'alert.sh'
95
- tracks:
96
- - id: track-1
97
- name: Track One
98
- color: '#3b82f6'
99
- driver: opencode
100
- model: opencode/big-pickle
101
- agent_profile: senior
102
- cwd: ./services/backend
103
- permissions:
104
- read: true
105
- write: true
106
- execute: false
107
- on_failure: skip_downstream
108
- middlewares:
109
- - type: static_context
110
- file: ./context.md
111
- label: Architecture Guide
112
- tasks:
113
- - id: task-a
114
- name: Do something
115
- prompt: 'Your prompt here'
116
- timeout: 10m
117
- driver: opencode
118
- model: opencode/big-pickle
119
- agent_profile: senior
120
- cwd: ./src
121
- permissions:
122
- read: true
123
- write: true
124
- execute: false
125
- middlewares:
126
- - type: static_context
127
- file: ./ref.md
128
- trigger:
129
- type: manual
130
- message: 'Approve before running'
131
- timeout: 5m
132
- completion:
133
- type: exit_code
134
- expect: 0
135
- - id: task-b
136
- name: Follow up
137
- prompt: 'Continue the work'
138
- continue_from: task-a
139
- depends_on: [task-a]
140
- ```
141
-
142
- ### Pipeline Fields
143
-
144
- | Field | Type | Required | Description |
145
- | --------- | --------------- | -------- | ------------------------------------------------------------------------------------------ |
146
- | `name` | `string` | Yes | Pipeline name, used in logs and run IDs |
147
- | `driver` | `string` | No | Default driver for all tracks/tasks (inherited). Built-in: `opencode` |
148
- | `model` | `string` | No | Default model for all tracks/tasks (inherited). Exact model name, e.g. `claude-sonnet-4-6` |
149
- | `timeout` | `string` | No | Pipeline-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
150
- | `plugins` | `string[]` | No | External plugin packages to load, e.g. `["@tagma/driver-codex"]` |
151
- | `hooks` | `HooksConfig` | No | Shell commands to run at lifecycle events (see Hooks below) |
152
- | `tracks` | `TrackConfig[]` | Yes | List of parallel execution tracks |
153
-
154
- ### Hooks Fields
155
-
156
- Each hook value can be a single command string or an array of commands.
157
-
158
- | Field | Type | Description |
159
- | ------------------- | -------------------- | -------------------------------------------- |
160
- | `pipeline_start` | `string \| string[]` | Runs when the pipeline begins |
161
- | `task_start` | `string \| string[]` | Runs before each task starts |
162
- | `task_success` | `string \| string[]` | Runs after a task succeeds |
163
- | `task_failure` | `string \| string[]` | Runs after a task fails |
164
- | `pipeline_complete` | `string \| string[]` | Runs when the pipeline finishes successfully |
165
- | `pipeline_error` | `string \| string[]` | Runs when the pipeline finishes with errors |
166
-
167
- ### Track Fields
168
-
169
- | Field | Type | Required | Default | Description |
170
- | --------------- | -------------------- | -------- | ----------------------- | -------------------------------------------------------------------- |
171
- | `id` | `string` | Yes | — | Unique track identifier |
172
- | `name` | `string` | Yes | — | Display name |
173
- | `color` | `string` | No | — | Color hint for UI rendering (e.g. `"#3b82f6"`) |
174
- | `driver` | `string` | No | Inherited from pipeline | Driver for all tasks in this track |
175
- | `model` | `string` | No | Inherited from pipeline | Exact model name passed to the driver CLI (e.g. `claude-sonnet-4-6`) |
176
- | `agent_profile` | `string` | No | — | Named agent configuration profile |
177
- | `cwd` | `string` | No | Pipeline workDir | Working directory for tasks in this track (relative path) |
178
- | `permissions` | `Permissions` | No | Inherited from pipeline | Default permissions for tasks (see Permissions) |
179
- | `on_failure` | `OnFailure` | No | `skip_downstream` | Failure strategy: `skip_downstream`, `stop_all`, `ignore` |
180
- | `middlewares` | `MiddlewareConfig[]` | No | — | Middlewares applied to all tasks (task-level overrides track-level) |
181
- | `tasks` | `TaskConfig[]` | Yes | — | Ordered list of tasks in this track |
182
-
183
- ### Task Fields
184
-
185
- | Field | Type | Required | Default | Description |
186
- | --------------- | -------------------- | -------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
187
- | `id` | `string` | Yes | — | Unique task identifier (unique within the pipeline) |
188
- | `name` | `string` | No | — | Display name |
189
- | `prompt` | `string` | No\* | — | AI prompt to send to the driver. \*Mutually exclusive with `command` |
190
- | `command` | `string` | No\* | — | Shell command to execute directly. \*Mutually exclusive with `prompt` |
191
- | `depends_on` | `string[]` | No | — | Task IDs that must complete before this task runs. Cross-track refs use `trackId.taskId` |
192
- | `continue_from` | `string` | No | — | Task ID whose output/session to continue from (session handoff). Cross-track refs use `trackId.taskId` |
193
- | `driver` | `string` | No | Inherited from track | Driver override for this task |
194
- | `model` | `string` | No | Inherited from track | Model name override for this task |
195
- | `agent_profile` | `string` | No | Inherited from track | Agent profile override |
196
- | `cwd` | `string` | No | Inherited from track | Working directory override (relative path) |
197
- | `timeout` | `string` | No | — | Task-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
198
- | `permissions` | `Permissions` | No | Inherited from track | Permission override (see Permissions) |
199
- | `middlewares` | `MiddlewareConfig[]` | No | Inherited from track | Middleware override. Set `[]` to disable inherited middlewares |
200
- | `trigger` | `TriggerConfig` | No | — | Gate that must resolve before the task runs (see Triggers) |
201
- | `completion` | `CompletionConfig` | No | — | Post-execution check to validate task output (see Completions) |
202
-
203
- ### Permissions
204
-
205
- | Field | Type | Default | Description |
206
- | --------- | --------- | ------- | ----------------------------------- |
207
- | `read` | `boolean` | — | Allow the agent to read files |
208
- | `write` | `boolean` | — | Allow the agent to write files |
209
- | `execute` | `boolean` | — | Allow the agent to execute commands |
210
-
211
- ### Inheritance
212
-
213
- Fields are inherited top-down: **pipeline → track → task**. A value set at a lower level overrides the inherited value.
214
-
215
- Inherited fields: `driver`, `model`, `permissions`, `cwd`, `middlewares`.
216
-
217
- Track-level `middlewares` apply to all tasks in the track. Setting task-level `middlewares` **replaces** (not appends) the track-level list. Use `middlewares: []` to disable all inherited middlewares for a task.
218
-
219
- ---
220
-
221
- ### Built-in Triggers
222
-
223
- #### `manual` — Human approval gate
224
-
225
- | Field | Type | Required | Default | Description |
226
- | ---------- | ---------- | -------- | ------------------------------------------------------ | ------------------------------------------------- |
227
- | `type` | `"manual"` | Yes | — | Trigger type |
228
- | `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
229
- | `timeout` | `string` | No | — | How long to wait for a decision before timing out |
230
- | `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
231
-
232
- #### `file` — File watcher gate
233
-
234
- | Field | Type | Required | Default | Description |
235
- | --------- | -------- | -------- | ------- | ---------------------------------------------- |
236
- | `type` | `"file"` | Yes | — | Trigger type |
237
- | `path` | `string` | Yes | — | File path to watch (relative to workDir) |
238
- | `timeout` | `string` | No | — | How long to wait for the file to appear/change |
239
-
240
- ---
241
-
242
- ### Built-in Completions
243
-
244
- #### `exit_code` — Exit code check
245
-
246
- | Field | Type | Required | Default | Description |
247
- | -------- | -------------------- | -------- | ------- | ------------------------------------------------------------------ |
248
- | `type` | `"exit_code"` | Yes | — | Completion type |
249
- | `expect` | `number \| number[]` | No | `0` | Expected exit code(s). Pass an array for multiple acceptable codes |
250
-
251
- #### `file_exists` — File existence check
252
-
253
- | Field | Type | Required | Default | Description |
254
- | ---------- | -------------------------- | -------- | ------- | ----------------------------------------------------- |
255
- | `type` | `"file_exists"` | Yes | — | Completion type |
256
- | `path` | `string` | Yes | — | File or directory path to check (relative to workDir) |
257
- | `kind` | `"file" \| "dir" \| "any"` | No | `"any"` | Entity type constraint |
258
- | `min_size` | `number` | No | — | Minimum file size in bytes (files only) |
259
-
260
- #### `output_check` — Command-based output validation
261
-
262
- | Field | Type | Required | Default | Description |
263
- | --------- | ---------------- | -------- | ------- | ----------------------------------------------------------------------- |
264
- | `type` | `"output_check"` | Yes | — | Completion type |
265
- | `check` | `string` | Yes | — | Shell command to run. Task stdout is piped to its stdin; exits 0 = pass |
266
- | `timeout` | `string` | No | `"30s"` | Max time to wait for the check command |
267
-
268
- ---
269
-
270
- ### Built-in Middlewares
271
-
272
- #### `static_context` — Prepend file content to prompt
273
-
274
- | Field | Type | Required | Default | Description |
275
- | ------- | ------------------ | -------- | ------------------------- | ---------------------------------------------- |
276
- | `type` | `"static_context"` | Yes | — | Middleware type |
277
- | `file` | `string` | Yes | — | Path to the context file (relative to workDir) |
278
- | `label` | `string` | No | `"Reference: {filename}"` | Label for the injected context section |
279
-
280
- ## API
281
-
282
- ### `bootstrapBuiltins()`
283
-
284
- Registers all built-in plugins (opencode driver, file/manual triggers, completion checks, static-context middleware).
285
-
286
- ### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
287
-
288
- Parses YAML, resolves inheritance, and validates the configuration.
289
-
290
- ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
291
-
292
- Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
293
-
294
- Options:
295
-
296
- - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
297
- - `signal` -- `AbortSignal` to cancel the run externally
298
- - `onEvent` -- callback for real-time `RunEventPayload` updates. Every payload carries `runId`. The editor server stamps a per-run `seq` on top of this payload before broadcasting over SSE (producing a `WireRunEvent`); the SDK itself does not stamp `seq`. Event variants:
299
- - `run_start` — pipeline approved and all tasks transitioned to `waiting`; includes `tasks: RunTaskState[]` (wire-shape snapshot of every task). Fires only when the `pipeline_start` hook allows the run — blocked pipelines emit no wire events at all.
300
- - `task_update` — a task's status or result changed; flat fields (`status`, `startedAt?`, `finishedAt?`, `durationMs?`, `exitCode?`, `stdout?`, `stderr?`, `stderrPath?`, `sessionId?`, `normalizedOutput?`, `resolvedDriver?`, `resolvedModel?`, `resolvedPermissions?`) so clients can fold partial updates with `??` semantics. `startedAt` is populated before the `running` transition; `finishedAt` and result fields are populated before any terminal-status transition. Terminal-state locking in the engine guarantees at most one terminal event per task.
301
- - `task_log` — a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
302
- - `run_end` — pipeline finished; includes `success: boolean` and `abortReason: 'timeout' | 'stop_all' | 'external' | null`. `null` means the run completed on its own steam (success may still be `false` if tasks failed).
303
- - `run_error` — reserved for fatal engine errors surfaced over the wire.
304
- - `approval_request` / `approval_resolved` — bridged from the approval gateway so hosts see approvals on the same channel as task updates, without separately subscribing to the gateway.
305
- - `runId` -- caller-supplied run ID. When provided the engine uses this instead of generating its own, keeping the caller and the SDK log directories aligned on the same ID
306
- - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
307
- - `skipPluginLoading` -- skip the engine's built-in `loadPlugins(config.plugins)` call. Set this when the host has already pre-loaded plugins from a custom resolution path (e.g. the editor loading from the user's workspace `node_modules`) so the engine doesn't re-resolve them via Node's default cwd-based import.
308
-
309
- ### `PipelineRunner`
310
-
311
- Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
312
-
313
- ```ts
314
- const runner = new PipelineRunner(config, workDir, options?); // options: Omit<RunPipelineOptions, 'signal' | 'onEvent'>
315
-
316
- // Subscribe before start — handler is called for every RunEventPayload
317
- const unsubscribe = runner.subscribe((event) => {
318
- tauriEmit('run_event', { id: runner.instanceId, event });
319
- });
320
-
321
- runner.start(); // returns Promise<EngineResult>, idempotent
322
-
323
- // Cancel from IPC
324
- runner.abort();
325
-
326
- // Live wire-shape task mirror, maintained from run_start + task_update events.
327
- // Empty map before the first run_start; safe to read at any time.
328
- const tasks = runner.getTasks(); // ReadonlyMap<taskId, RunTaskState>
329
- ```
330
-
331
- Properties:
332
-
333
- - `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
334
- - `runId` — engine-assigned run ID, available after the first `run_start` event (`null` until then)
335
- - `status` — `'idle' | 'running' | 'done' | 'aborted'` (see `PipelineRunnerStatus`)
336
-
337
- ### `TriggerBlockedError` / `TriggerTimeoutError`
338
-
339
- Typed error classes for trigger plugin error classification. The engine uses `instanceof` checks on these to set the correct task status (`blocked` or `timeout`) instead of matching on error message substrings.
340
-
341
- Built-in triggers (`manual`, `file`) throw these automatically. Third-party trigger plugins should throw `TriggerBlockedError` for user/policy rejections and `TriggerTimeoutError` for genuine wait timeouts. Plugins that throw plain `Error` still work — the engine falls back to string matching for backward compatibility, but typed errors are preferred to avoid misclassification from coincidental substrings.
342
-
343
- ```ts
344
- import { TriggerBlockedError, TriggerTimeoutError } from '@tagma/sdk';
345
-
346
- // In a custom trigger plugin:
347
- throw new TriggerBlockedError('Access denied by policy');
348
- throw new TriggerTimeoutError('File did not appear within 30s');
349
- ```
350
-
351
- ### `loadPlugins(names: string[]): Promise<void>`
352
-
353
- Dynamically loads and registers external plugin packages.
354
-
355
- ### `registerPlugin(category, type, handler): void`
356
-
357
- Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
358
-
359
- Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
360
-
361
- ```ts
362
- import type { TriggerPlugin } from '@tagma/types';
363
-
364
- export const HttpTrigger: TriggerPlugin = {
365
- name: 'http',
366
- schema: {
367
- description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
368
- fields: {
369
- url: { type: 'string', required: true, placeholder: 'https://...' },
370
- method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
371
- timeout: { type: 'duration', description: 'Give up after this long.' },
372
- },
373
- },
374
- async watch(config, ctx) {
375
- /* ... */
376
- },
377
- };
378
- ```
379
-
380
- The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
381
-
382
- ### `getHandler(category, type): PluginType`
383
-
384
- Retrieves a registered plugin handler. Throws if the plugin is not registered.
385
-
386
- ### `hasHandler(category, type): boolean`
387
-
388
- Returns `true` if a handler is registered for the given category and type.
389
-
390
- ### `listRegistered(category): string[]`
391
-
392
- Lists all registered handler type names for a plugin category (`'drivers'`, `'triggers'`, `'completions'`, `'middlewares'`).
393
-
394
- ### `resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig`
395
-
396
- Resolves a raw pipeline config into a fully resolved `PipelineConfig` — applies inheritance (pipeline → track → task) for driver, model, permissions, and cwd. Validates and resolves all file paths against `workDir`.
397
-
398
- Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` directly when you need to manipulate the raw config between parsing and resolution.
399
-
400
- ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
401
-
402
- Attaches an interactive stdin-based approval handler.
403
-
404
- ### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
405
-
406
- Starts a WebSocket server for remote approval decisions.
407
-
408
- ### Config CRUD (`config-ops`)
409
-
410
- Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
411
-
412
- ```ts
413
- import {
414
- createEmptyPipeline,
415
- setPipelineField,
416
- upsertTrack,
417
- removeTrack,
418
- moveTrack,
419
- updateTrack,
420
- upsertTask,
421
- removeTask,
422
- moveTask,
423
- transferTask,
424
- serializePipeline,
425
- } from '@tagma/sdk';
426
-
427
- // Build a config programmatically
428
- let config = createEmptyPipeline('my-pipeline');
429
- config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
430
- config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
431
-
432
- // Sync back to YAML
433
- const yaml = serializePipeline(config);
434
- ```
435
-
436
- | Function | Description |
437
- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
438
- | `createEmptyPipeline(name)` | Create a minimal pipeline config |
439
- | `setPipelineField(config, fields)` | Update top-level pipeline fields |
440
- | `upsertTrack(config, track)` | Insert or replace a track by id |
441
- | `removeTrack(config, trackId)` | Remove a track |
442
- | `moveTrack(config, trackId, toIndex)` | Reorder a track |
443
- | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
444
- | `upsertTask(config, trackId, task)` | Insert or replace a task |
445
- | `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
446
- | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
447
- | `transferTask(config, fromTrackId, taskId, toTrackId, qualifyRefs?)` | Move a task across tracks. When `qualifyRefs` is `true` (default), bare `depends_on` / `continue_from` references to the moved task are converted to fully-qualified form (`toTrackId.taskId`) so same-track resolution stays correct |
448
-
449
- ### `parseYaml(content: string): RawPipelineConfig`
450
-
451
- Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
452
-
453
- ### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
454
-
455
- Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
456
-
457
- Use this when you have a programmatically modified resolved config and need to save it back to YAML:
458
-
459
- ```ts
460
- // Correct: load → modify resolved config → deresolve → save
461
- const config = await loadPipeline(yaml, workDir);
462
- const modified = { ...config, name: 'renamed' };
463
- const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
464
-
465
- // Also correct: work entirely in raw space (preferred for visual editors)
466
- const raw = parseYaml(yaml);
467
- const updatedRaw = setPipelineField(raw, { name: 'renamed' });
468
- const savedYaml = serializePipeline(updatedRaw);
469
- ```
470
-
471
- ### `validateConfig(config: PipelineConfig): string[]`
472
-
473
- Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
474
-
475
- Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
476
-
477
- ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
478
-
479
- Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
480
-
481
- Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
482
-
483
- Does **not** check plugin registration (plugins may not be loaded at edit time).
484
-
485
- ```ts
486
- const errors = validateRaw(draftConfig);
487
- if (errors.length > 0) {
488
- errors.forEach((e) => highlightNode(e.path, e.message));
489
- }
490
- ```
491
-
492
- ### `buildRawDag(config: RawPipelineConfig): RawDag`
493
-
494
- Extracts the topology of a raw (unresolved) pipeline config as a graph — no `workDir` or plugin registration required. Intended for the visual editor to render the flow graph during editing.
495
-
496
- Returns `{ nodes: ReadonlyMap<taskId, RawDagNode>, edges: { from, to }[] }` where each edge represents a dependency (from must complete before to). Unresolvable refs are silently skipped.
497
-
498
- ```ts
499
- const { nodes, edges } = buildRawDag(draftConfig);
500
- // nodes — keyed by "trackId.taskId"
501
- // edges — [{ from: "track.taskA", to: "track.taskB" }, ...]
502
- ```
503
-
504
- Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need topological sort order.
505
-
506
- ### `Logger`
507
-
508
- Dual-channel logger — console + file. Creates a per-run log file at `<workDir>/.tagma/logs/<runId>/pipeline.log`.
509
-
510
- ```ts
511
- const logger = new Logger(workDir, runId);
512
- logger.info('[track]', 'message'); // console + file
513
- logger.warn('[track]', 'message'); // console + file
514
- logger.error('[track]', 'message'); // console + file
515
- logger.debug('[track]', 'message'); // file only
516
- logger.section('Title'); // file only — visual separator
517
- logger.quiet(bulkText); // file only — bulk payload
518
- logger.path; // log file path
519
- logger.dir; // run artifact directory
520
- logger.close(); // close the persistent file handle (called automatically by runPipeline at run completion)
521
- ```
522
-
523
- Pass an optional third argument to stream every appended line out as a
524
- structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
525
-
526
- ```ts
527
- import { Logger, type LogRecord } from '@tagma/sdk';
528
-
529
- const logger = new Logger(workDir, runId, (record: LogRecord) => {
530
- // record = { level, taskId, timestamp, text }
531
- // level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
532
- // taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
533
- forwardToUI(record);
534
- });
535
- ```
536
-
537
- `section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
538
- line logically belongs to a task — the extractor cannot infer one otherwise:
539
-
540
- ```ts
541
- logger.section(`Task ${taskId}`, taskId);
542
- logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
543
- ```
544
-
545
- ### `tailLines(text: string, n: number): string`
546
-
547
- Returns the last `n` non-empty lines of `text`, joined with newlines.
548
-
549
- ### `clip(text: string, maxBytes?: number): string`
550
-
551
- Truncates `text` to at most `maxBytes` UTF-8 bytes (default 16 KB), appending a `…[truncated N bytes]` marker when truncation occurs. Multi-byte characters (CJK, emoji) are counted correctly.
552
-
553
- ### Utilities
554
-
555
- | Function | Description |
556
- | ------------------------------------- | --------------------------------------------------------- |
557
- | `parseDuration(input)` | Parses `"30s"`, `"5m"`, `"2h"` → milliseconds |
558
- | `validatePath(filePath, projectRoot)` | Resolves path, throws if it escapes project root |
559
- | `generateRunId()` | Generates a unique run ID (`run_<ts>_<seq>_<rand>`) |
560
- | `nowISO()` | Returns `new Date().toISOString()` |
561
- | `truncateForName(text, maxLen?)` | Truncates first line to `maxLen` (default 40) for display |
562
-
563
- ## Related Packages
564
-
565
- | Package | Description |
566
- | ------------------------------------------------------------------------------ | -------------------------- |
567
- | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
568
- | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
569
- | [@tagma/driver-claude-code](https://www.npmjs.com/package/@tagma/driver-claude-code) | Claude Code CLI driver plugin |
570
-
571
- ## License
572
-
573
- MIT
1
+ # @tagma/sdk
2
+
3
+ > ## ⚠️ Bun-only runtime
4
+ >
5
+ > ```bash
6
+ > bun add @tagma/sdk
7
+ > ```
8
+ >
9
+ > This package is published as pre-built JavaScript (`dist/`), but the runtime
10
+ > uses Bun-only APIs (`Bun.spawn`, `Bun.file`, `Bun.serve`). It will install
11
+ > under `npm` / `yarn` / `pnpm` without error, but **crash at runtime on Node**
12
+ > the first time a pipeline spawns a task. Get Bun at <https://bun.sh>.
13
+ >
14
+ > _(The `npm i @tagma/sdk` line in the npmjs.com sidebar is auto-generated by
15
+ > the npm registry website and cannot be removed — please ignore it and use
16
+ > the command above.)_
17
+
18
+ A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (OpenCode, Codex, Claude Code) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
19
+
20
+ ## Install
21
+
22
+ **Requires Bun ≥ 1.3.** Node/npm are not supported.
23
+
24
+ ```bash
25
+ bun add @tagma/sdk
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ **1. Define a pipeline** (`pipeline.yaml`)
31
+
32
+ ```yaml
33
+ pipeline:
34
+ name: build-and-test
35
+ tracks:
36
+ - id: backend
37
+ name: Backend
38
+ driver: opencode
39
+ permissions: { read: true, write: true, execute: false }
40
+ tasks:
41
+ - id: implement
42
+ name: Implement feature
43
+ prompt: 'Add a /health endpoint to src/server.ts'
44
+ - id: test
45
+ name: Run tests
46
+ command: 'bun test'
47
+ depends_on: [implement]
48
+ ```
49
+
50
+ **2. Run it programmatically**
51
+
52
+ ```ts
53
+ import { bootstrapBuiltins, loadPipeline, runPipeline, InMemoryApprovalGateway } from '@tagma/sdk';
54
+
55
+ // Register built-in drivers, triggers, completions
56
+ bootstrapBuiltins();
57
+
58
+ const yaml = await Bun.file('pipeline.yaml').text();
59
+ const config = await loadPipeline(yaml, process.cwd());
60
+
61
+ const result = await runPipeline(config, process.cwd());
62
+ console.log(result.success ? 'Done' : 'Failed');
63
+ ```
64
+
65
+ ## Features
66
+
67
+ - **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
68
+ - **Driver plugins** -- built-in `opencode` driver; install `@tagma/driver-codex` or `@tagma/driver-claude-code` for other agents
69
+ - **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
70
+ - **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
71
+ - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
72
+ - **Middleware** -- enrich prompts before execution (e.g. inject static context)
73
+ - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
74
+ - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
75
+
76
+ ## Pipeline YAML Reference
77
+
78
+ ### Full Structure
79
+
80
+ ```yaml
81
+ pipeline:
82
+ name: my-pipeline
83
+ driver: opencode
84
+ timeout: 30m
85
+ plugins:
86
+ - '@tagma/driver-codex'
87
+ - '@tagma/driver-claude-code'
88
+ hooks:
89
+ pipeline_start: 'echo starting'
90
+ task_start: 'echo task begin'
91
+ task_success: 'echo task ok'
92
+ task_failure: 'notify-slack.sh'
93
+ pipeline_complete: 'echo done'
94
+ pipeline_error: 'alert.sh'
95
+ tracks:
96
+ - id: track-1
97
+ name: Track One
98
+ color: '#3b82f6'
99
+ driver: opencode
100
+ model: opencode/big-pickle
101
+ agent_profile: senior
102
+ cwd: ./services/backend
103
+ permissions:
104
+ read: true
105
+ write: true
106
+ execute: false
107
+ on_failure: skip_downstream
108
+ middlewares:
109
+ - type: static_context
110
+ file: ./context.md
111
+ label: Architecture Guide
112
+ tasks:
113
+ - id: task-a
114
+ name: Do something
115
+ prompt: 'Your prompt here'
116
+ timeout: 10m
117
+ driver: opencode
118
+ model: opencode/big-pickle
119
+ agent_profile: senior
120
+ cwd: ./src
121
+ permissions:
122
+ read: true
123
+ write: true
124
+ execute: false
125
+ middlewares:
126
+ - type: static_context
127
+ file: ./ref.md
128
+ trigger:
129
+ type: manual
130
+ message: 'Approve before running'
131
+ timeout: 5m
132
+ completion:
133
+ type: exit_code
134
+ expect: 0
135
+ - id: task-b
136
+ name: Follow up
137
+ prompt: 'Continue the work'
138
+ continue_from: task-a
139
+ depends_on: [task-a]
140
+ ```
141
+
142
+ ### Pipeline Fields
143
+
144
+ | Field | Type | Required | Description |
145
+ | --------- | --------------- | -------- | ------------------------------------------------------------------------------------------ |
146
+ | `name` | `string` | Yes | Pipeline name, used in logs and run IDs |
147
+ | `driver` | `string` | No | Default driver for all tracks/tasks (inherited). Built-in: `opencode` |
148
+ | `model` | `string` | No | Default model for all tracks/tasks (inherited). Exact model name, e.g. `claude-sonnet-4-6` |
149
+ | `timeout` | `string` | No | Pipeline-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
150
+ | `plugins` | `string[]` | No | External plugin packages to load, e.g. `["@tagma/driver-codex"]` |
151
+ | `hooks` | `HooksConfig` | No | Shell commands to run at lifecycle events (see Hooks below) |
152
+ | `tracks` | `TrackConfig[]` | Yes | List of parallel execution tracks |
153
+
154
+ ### Hooks Fields
155
+
156
+ Each hook value can be a single command string or an array of commands.
157
+
158
+ | Field | Type | Description |
159
+ | ------------------- | -------------------- | -------------------------------------------- |
160
+ | `pipeline_start` | `string \| string[]` | Runs when the pipeline begins |
161
+ | `task_start` | `string \| string[]` | Runs before each task starts |
162
+ | `task_success` | `string \| string[]` | Runs after a task succeeds |
163
+ | `task_failure` | `string \| string[]` | Runs after a task fails |
164
+ | `pipeline_complete` | `string \| string[]` | Runs when the pipeline finishes successfully |
165
+ | `pipeline_error` | `string \| string[]` | Runs when the pipeline finishes with errors |
166
+
167
+ ### Track Fields
168
+
169
+ | Field | Type | Required | Default | Description |
170
+ | --------------- | -------------------- | -------- | ----------------------- | -------------------------------------------------------------------- |
171
+ | `id` | `string` | Yes | — | Unique track identifier |
172
+ | `name` | `string` | Yes | — | Display name |
173
+ | `color` | `string` | No | — | Color hint for UI rendering (e.g. `"#3b82f6"`) |
174
+ | `driver` | `string` | No | Inherited from pipeline | Driver for all tasks in this track |
175
+ | `model` | `string` | No | Inherited from pipeline | Exact model name passed to the driver CLI (e.g. `claude-sonnet-4-6`) |
176
+ | `agent_profile` | `string` | No | — | Named agent configuration profile |
177
+ | `cwd` | `string` | No | Pipeline workDir | Working directory for tasks in this track (relative path) |
178
+ | `permissions` | `Permissions` | No | Inherited from pipeline | Default permissions for tasks (see Permissions) |
179
+ | `on_failure` | `OnFailure` | No | `skip_downstream` | Failure strategy: `skip_downstream`, `stop_all`, `ignore` |
180
+ | `middlewares` | `MiddlewareConfig[]` | No | — | Middlewares applied to all tasks (task-level overrides track-level) |
181
+ | `tasks` | `TaskConfig[]` | Yes | — | Ordered list of tasks in this track |
182
+
183
+ ### Task Fields
184
+
185
+ | Field | Type | Required | Default | Description |
186
+ | --------------- | -------------------- | -------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
187
+ | `id` | `string` | Yes | — | Unique task identifier (unique within the pipeline) |
188
+ | `name` | `string` | No | — | Display name |
189
+ | `prompt` | `string` | No\* | — | AI prompt to send to the driver. \*Mutually exclusive with `command` |
190
+ | `command` | `string` | No\* | — | Shell command to execute directly. \*Mutually exclusive with `prompt` |
191
+ | `depends_on` | `string[]` | No | — | Task IDs that must complete before this task runs. Cross-track refs use `trackId.taskId` |
192
+ | `continue_from` | `string` | No | — | Task ID whose output/session to continue from (session handoff). Cross-track refs use `trackId.taskId` |
193
+ | `driver` | `string` | No | Inherited from track | Driver override for this task |
194
+ | `model` | `string` | No | Inherited from track | Model name override for this task |
195
+ | `agent_profile` | `string` | No | Inherited from track | Agent profile override |
196
+ | `cwd` | `string` | No | Inherited from track | Working directory override (relative path) |
197
+ | `timeout` | `string` | No | — | Task-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
198
+ | `permissions` | `Permissions` | No | Inherited from track | Permission override (see Permissions) |
199
+ | `middlewares` | `MiddlewareConfig[]` | No | Inherited from track | Middleware override. Set `[]` to disable inherited middlewares |
200
+ | `trigger` | `TriggerConfig` | No | — | Gate that must resolve before the task runs (see Triggers) |
201
+ | `completion` | `CompletionConfig` | No | — | Post-execution check to validate task output (see Completions) |
202
+
203
+ ### Permissions
204
+
205
+ | Field | Type | Default | Description |
206
+ | --------- | --------- | ------- | ----------------------------------- |
207
+ | `read` | `boolean` | — | Allow the agent to read files |
208
+ | `write` | `boolean` | — | Allow the agent to write files |
209
+ | `execute` | `boolean` | — | Allow the agent to execute commands |
210
+
211
+ ### Inheritance
212
+
213
+ Fields are inherited top-down: **pipeline → track → task**. A value set at a lower level overrides the inherited value.
214
+
215
+ Inherited fields: `driver`, `model`, `permissions`, `cwd`, `middlewares`.
216
+
217
+ Track-level `middlewares` apply to all tasks in the track. Setting task-level `middlewares` **replaces** (not appends) the track-level list. Use `middlewares: []` to disable all inherited middlewares for a task.
218
+
219
+ ---
220
+
221
+ ### Built-in Triggers
222
+
223
+ #### `manual` — Human approval gate
224
+
225
+ | Field | Type | Required | Default | Description |
226
+ | ---------- | ---------- | -------- | ------------------------------------------------------ | ------------------------------------------------- |
227
+ | `type` | `"manual"` | Yes | — | Trigger type |
228
+ | `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
229
+ | `timeout` | `string` | No | — | How long to wait for a decision before timing out |
230
+ | `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
231
+
232
+ #### `file` — File watcher gate
233
+
234
+ | Field | Type | Required | Default | Description |
235
+ | --------- | -------- | -------- | ------- | ---------------------------------------------- |
236
+ | `type` | `"file"` | Yes | — | Trigger type |
237
+ | `path` | `string` | Yes | — | File path to watch (relative to workDir) |
238
+ | `timeout` | `string` | No | — | How long to wait for the file to appear/change |
239
+
240
+ ---
241
+
242
+ ### Built-in Completions
243
+
244
+ #### `exit_code` — Exit code check
245
+
246
+ | Field | Type | Required | Default | Description |
247
+ | -------- | -------------------- | -------- | ------- | ------------------------------------------------------------------ |
248
+ | `type` | `"exit_code"` | Yes | — | Completion type |
249
+ | `expect` | `number \| number[]` | No | `0` | Expected exit code(s). Pass an array for multiple acceptable codes |
250
+
251
+ #### `file_exists` — File existence check
252
+
253
+ | Field | Type | Required | Default | Description |
254
+ | ---------- | -------------------------- | -------- | ------- | ----------------------------------------------------- |
255
+ | `type` | `"file_exists"` | Yes | — | Completion type |
256
+ | `path` | `string` | Yes | — | File or directory path to check (relative to workDir) |
257
+ | `kind` | `"file" \| "dir" \| "any"` | No | `"any"` | Entity type constraint |
258
+ | `min_size` | `number` | No | — | Minimum file size in bytes (files only) |
259
+
260
+ #### `output_check` — Command-based output validation
261
+
262
+ | Field | Type | Required | Default | Description |
263
+ | --------- | ---------------- | -------- | ------- | ----------------------------------------------------------------------- |
264
+ | `type` | `"output_check"` | Yes | — | Completion type |
265
+ | `check` | `string` | Yes | — | Shell command to run. Task stdout is piped to its stdin; exits 0 = pass |
266
+ | `timeout` | `string` | No | `"30s"` | Max time to wait for the check command |
267
+
268
+ ---
269
+
270
+ ### Built-in Middlewares
271
+
272
+ #### `static_context` — Prepend file content to prompt
273
+
274
+ | Field | Type | Required | Default | Description |
275
+ | ------- | ------------------ | -------- | ------------------------- | ---------------------------------------------- |
276
+ | `type` | `"static_context"` | Yes | — | Middleware type |
277
+ | `file` | `string` | Yes | — | Path to the context file (relative to workDir) |
278
+ | `label` | `string` | No | `"Reference: {filename}"` | Label for the injected context section |
279
+
280
+ ## API
281
+
282
+ ### `bootstrapBuiltins()`
283
+
284
+ Registers all built-in plugins (opencode driver, file/manual triggers, completion checks, static-context middleware).
285
+
286
+ ### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
287
+
288
+ Parses YAML, resolves inheritance, and validates the configuration.
289
+
290
+ ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
291
+
292
+ Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
293
+
294
+ Options:
295
+
296
+ - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
297
+ - `signal` -- `AbortSignal` to cancel the run externally
298
+ - `onEvent` -- callback for real-time `RunEventPayload` updates. Every payload carries `runId`. The editor server stamps a per-run `seq` on top of this payload before broadcasting over SSE (producing a `WireRunEvent`); the SDK itself does not stamp `seq`. Event variants:
299
+ - `run_start` — pipeline approved and all tasks transitioned to `waiting`; includes `tasks: RunTaskState[]` (wire-shape snapshot of every task). Fires only when the `pipeline_start` hook allows the run — blocked pipelines emit no wire events at all.
300
+ - `task_update` — a task's status or result changed; flat fields (`status`, `startedAt?`, `finishedAt?`, `durationMs?`, `exitCode?`, `stdout?`, `stderr?`, `stderrPath?`, `sessionId?`, `normalizedOutput?`, `resolvedDriver?`, `resolvedModel?`, `resolvedPermissions?`) so clients can fold partial updates with `??` semantics. `startedAt` is populated before the `running` transition; `finishedAt` and result fields are populated before any terminal-status transition. Terminal-state locking in the engine guarantees at most one terminal event per task.
301
+ - `task_log` — a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
302
+ - `run_end` — pipeline finished; includes `success: boolean` and `abortReason: 'timeout' | 'stop_all' | 'external' | null`. `null` means the run completed on its own steam (success may still be `false` if tasks failed).
303
+ - `run_error` — reserved for fatal engine errors surfaced over the wire.
304
+ - `approval_request` / `approval_resolved` — bridged from the approval gateway so hosts see approvals on the same channel as task updates, without separately subscribing to the gateway.
305
+ - `runId` -- caller-supplied run ID. When provided the engine uses this instead of generating its own, keeping the caller and the SDK log directories aligned on the same ID
306
+ - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
307
+ - `skipPluginLoading` -- skip the engine's built-in `loadPlugins(config.plugins)` call. Set this when the host has already pre-loaded plugins from a custom resolution path (e.g. the editor loading from the user's workspace `node_modules`) so the engine doesn't re-resolve them via Node's default cwd-based import.
308
+
309
+ ### `PipelineRunner`
310
+
311
+ Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
312
+
313
+ ```ts
314
+ const runner = new PipelineRunner(config, workDir, options?); // options: Omit<RunPipelineOptions, 'signal' | 'onEvent'>
315
+
316
+ // Subscribe before start — handler is called for every RunEventPayload
317
+ const unsubscribe = runner.subscribe((event) => {
318
+ tauriEmit('run_event', { id: runner.instanceId, event });
319
+ });
320
+
321
+ runner.start(); // returns Promise<EngineResult>, idempotent
322
+
323
+ // Cancel from IPC
324
+ runner.abort();
325
+
326
+ // Live wire-shape task mirror, maintained from run_start + task_update events.
327
+ // Empty map before the first run_start; safe to read at any time.
328
+ const tasks = runner.getTasks(); // ReadonlyMap<taskId, RunTaskState>
329
+ ```
330
+
331
+ Properties:
332
+
333
+ - `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
334
+ - `runId` — engine-assigned run ID, available after the first `run_start` event (`null` until then)
335
+ - `status` — `'idle' | 'running' | 'done' | 'aborted'` (see `PipelineRunnerStatus`)
336
+
337
+ ### `TriggerBlockedError` / `TriggerTimeoutError`
338
+
339
+ Typed error classes for trigger plugin error classification. The engine uses `instanceof` checks on these to set the correct task status (`blocked` or `timeout`) instead of matching on error message substrings.
340
+
341
+ Built-in triggers (`manual`, `file`) throw these automatically. Third-party trigger plugins should throw `TriggerBlockedError` for user/policy rejections and `TriggerTimeoutError` for genuine wait timeouts. Plugins that throw plain `Error` still work — the engine falls back to string matching for backward compatibility, but typed errors are preferred to avoid misclassification from coincidental substrings.
342
+
343
+ ```ts
344
+ import { TriggerBlockedError, TriggerTimeoutError } from '@tagma/sdk';
345
+
346
+ // In a custom trigger plugin:
347
+ throw new TriggerBlockedError('Access denied by policy');
348
+ throw new TriggerTimeoutError('File did not appear within 30s');
349
+ ```
350
+
351
+ ### `loadPlugins(names: string[]): Promise<void>`
352
+
353
+ Dynamically loads and registers external plugin packages.
354
+
355
+ ### `registerPlugin(category, type, handler): void`
356
+
357
+ Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
358
+
359
+ Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
360
+
361
+ ```ts
362
+ import type { TriggerPlugin } from '@tagma/types';
363
+
364
+ export const HttpTrigger: TriggerPlugin = {
365
+ name: 'http',
366
+ schema: {
367
+ description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
368
+ fields: {
369
+ url: { type: 'string', required: true, placeholder: 'https://...' },
370
+ method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
371
+ timeout: { type: 'duration', description: 'Give up after this long.' },
372
+ },
373
+ },
374
+ async watch(config, ctx) {
375
+ /* ... */
376
+ },
377
+ };
378
+ ```
379
+
380
+ The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
381
+
382
+ ### `getHandler(category, type): PluginType`
383
+
384
+ Retrieves a registered plugin handler. Throws if the plugin is not registered.
385
+
386
+ ### `hasHandler(category, type): boolean`
387
+
388
+ Returns `true` if a handler is registered for the given category and type.
389
+
390
+ ### `listRegistered(category): string[]`
391
+
392
+ Lists all registered handler type names for a plugin category (`'drivers'`, `'triggers'`, `'completions'`, `'middlewares'`).
393
+
394
+ ### `resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig`
395
+
396
+ Resolves a raw pipeline config into a fully resolved `PipelineConfig` — applies inheritance (pipeline → track → task) for driver, model, permissions, and cwd. Validates and resolves all file paths against `workDir`.
397
+
398
+ Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` directly when you need to manipulate the raw config between parsing and resolution.
399
+
400
+ ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
401
+
402
+ Attaches an interactive stdin-based approval handler.
403
+
404
+ ### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
405
+
406
+ Starts a WebSocket server for remote approval decisions.
407
+
408
+ ### Config CRUD (`config-ops`)
409
+
410
+ Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
411
+
412
+ ```ts
413
+ import {
414
+ createEmptyPipeline,
415
+ setPipelineField,
416
+ upsertTrack,
417
+ removeTrack,
418
+ moveTrack,
419
+ updateTrack,
420
+ upsertTask,
421
+ removeTask,
422
+ moveTask,
423
+ transferTask,
424
+ serializePipeline,
425
+ } from '@tagma/sdk';
426
+
427
+ // Build a config programmatically
428
+ let config = createEmptyPipeline('my-pipeline');
429
+ config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
430
+ config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
431
+
432
+ // Sync back to YAML
433
+ const yaml = serializePipeline(config);
434
+ ```
435
+
436
+ | Function | Description |
437
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
438
+ | `createEmptyPipeline(name)` | Create a minimal pipeline config |
439
+ | `setPipelineField(config, fields)` | Update top-level pipeline fields |
440
+ | `upsertTrack(config, track)` | Insert or replace a track by id |
441
+ | `removeTrack(config, trackId)` | Remove a track |
442
+ | `moveTrack(config, trackId, toIndex)` | Reorder a track |
443
+ | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
444
+ | `upsertTask(config, trackId, task)` | Insert or replace a task |
445
+ | `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
446
+ | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
447
+ | `transferTask(config, fromTrackId, taskId, toTrackId, qualifyRefs?)` | Move a task across tracks. When `qualifyRefs` is `true` (default), bare `depends_on` / `continue_from` references to the moved task are converted to fully-qualified form (`toTrackId.taskId`) so same-track resolution stays correct |
448
+
449
+ ### `parseYaml(content: string): RawPipelineConfig`
450
+
451
+ Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
452
+
453
+ ### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
454
+
455
+ Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
456
+
457
+ Use this when you have a programmatically modified resolved config and need to save it back to YAML:
458
+
459
+ ```ts
460
+ // Correct: load → modify resolved config → deresolve → save
461
+ const config = await loadPipeline(yaml, workDir);
462
+ const modified = { ...config, name: 'renamed' };
463
+ const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
464
+
465
+ // Also correct: work entirely in raw space (preferred for visual editors)
466
+ const raw = parseYaml(yaml);
467
+ const updatedRaw = setPipelineField(raw, { name: 'renamed' });
468
+ const savedYaml = serializePipeline(updatedRaw);
469
+ ```
470
+
471
+ ### `validateConfig(config: PipelineConfig): string[]`
472
+
473
+ Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
474
+
475
+ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
476
+
477
+ ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
478
+
479
+ Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
480
+
481
+ Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
482
+
483
+ Does **not** check plugin registration (plugins may not be loaded at edit time).
484
+
485
+ ```ts
486
+ const errors = validateRaw(draftConfig);
487
+ if (errors.length > 0) {
488
+ errors.forEach((e) => highlightNode(e.path, e.message));
489
+ }
490
+ ```
491
+
492
+ ### `buildRawDag(config: RawPipelineConfig): RawDag`
493
+
494
+ Extracts the topology of a raw (unresolved) pipeline config as a graph — no `workDir` or plugin registration required. Intended for the visual editor to render the flow graph during editing.
495
+
496
+ Returns `{ nodes: ReadonlyMap<taskId, RawDagNode>, edges: { from, to }[] }` where each edge represents a dependency (from must complete before to). Unresolvable refs are silently skipped.
497
+
498
+ ```ts
499
+ const { nodes, edges } = buildRawDag(draftConfig);
500
+ // nodes — keyed by "trackId.taskId"
501
+ // edges — [{ from: "track.taskA", to: "track.taskB" }, ...]
502
+ ```
503
+
504
+ Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need topological sort order.
505
+
506
+ ### `Logger`
507
+
508
+ Dual-channel logger — console + file. Creates a per-run log file at `<workDir>/.tagma/logs/<runId>/pipeline.log`.
509
+
510
+ ```ts
511
+ const logger = new Logger(workDir, runId);
512
+ logger.info('[track]', 'message'); // console + file
513
+ logger.warn('[track]', 'message'); // console + file
514
+ logger.error('[track]', 'message'); // console + file
515
+ logger.debug('[track]', 'message'); // file only
516
+ logger.section('Title'); // file only — visual separator
517
+ logger.quiet(bulkText); // file only — bulk payload
518
+ logger.path; // log file path
519
+ logger.dir; // run artifact directory
520
+ logger.close(); // close the persistent file handle (called automatically by runPipeline at run completion)
521
+ ```
522
+
523
+ Pass an optional third argument to stream every appended line out as a
524
+ structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
525
+
526
+ ```ts
527
+ import { Logger, type LogRecord } from '@tagma/sdk';
528
+
529
+ const logger = new Logger(workDir, runId, (record: LogRecord) => {
530
+ // record = { level, taskId, timestamp, text }
531
+ // level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
532
+ // taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
533
+ forwardToUI(record);
534
+ });
535
+ ```
536
+
537
+ `section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
538
+ line logically belongs to a task — the extractor cannot infer one otherwise:
539
+
540
+ ```ts
541
+ logger.section(`Task ${taskId}`, taskId);
542
+ logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
543
+ ```
544
+
545
+ ### `tailLines(text: string, n: number): string`
546
+
547
+ Returns the last `n` non-empty lines of `text`, joined with newlines.
548
+
549
+ ### `clip(text: string, maxBytes?: number): string`
550
+
551
+ Truncates `text` to at most `maxBytes` UTF-8 bytes (default 16 KB), appending a `…[truncated N bytes]` marker when truncation occurs. Multi-byte characters (CJK, emoji) are counted correctly.
552
+
553
+ ### Utilities
554
+
555
+ | Function | Description |
556
+ | ------------------------------------- | --------------------------------------------------------- |
557
+ | `parseDuration(input)` | Parses `"30s"`, `"5m"`, `"2h"` → milliseconds |
558
+ | `validatePath(filePath, projectRoot)` | Resolves path, throws if it escapes project root |
559
+ | `generateRunId()` | Generates a unique run ID (`run_<ts>_<seq>_<rand>`) |
560
+ | `nowISO()` | Returns `new Date().toISOString()` |
561
+ | `truncateForName(text, maxLen?)` | Truncates first line to `maxLen` (default 40) for display |
562
+
563
+ ## Related Packages
564
+
565
+ | Package | Description |
566
+ | ------------------------------------------------------------------------------ | -------------------------- |
567
+ | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
568
+ | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
569
+ | [@tagma/driver-claude-code](https://www.npmjs.com/package/@tagma/driver-claude-code) | Claude Code CLI driver plugin |
570
+
571
+ ## License
572
+
573
+ MIT