@tagma/sdk 0.1.3 → 0.1.5

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,139 +1,245 @@
1
- # @tagma/sdk
2
-
3
- A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (Claude Code, Codex, OpenCode) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
4
-
5
- ## Install
6
-
7
- ```bash
8
- bun add @tagma/sdk
9
- ```
10
-
11
- ## Quick Start
12
-
13
- **1. Define a pipeline** (`pipeline.yaml`)
14
-
15
- ```yaml
16
- pipeline:
17
- name: build-and-test
18
- tracks:
19
- - id: backend
20
- name: Backend
21
- driver: claude-code
22
- permissions: { read: true, write: true, execute: false }
23
- tasks:
24
- - id: implement
25
- name: Implement feature
26
- prompt: "Add a /health endpoint to src/server.ts"
27
- output: ./output/implement.txt
28
- - id: test
29
- name: Run tests
30
- command: "bun test"
31
- depends_on: [implement]
32
- ```
33
-
34
- **2. Run it programmatically**
35
-
36
- ```ts
37
- import {
38
- bootstrapBuiltins,
39
- loadPipeline,
40
- runPipeline,
41
- InMemoryApprovalGateway,
42
- } from '@tagma/sdk';
43
-
44
- // Register built-in drivers, triggers, completions
45
- bootstrapBuiltins();
46
-
47
- const yaml = await Bun.file('pipeline.yaml').text();
48
- const config = await loadPipeline(yaml, process.cwd());
49
-
50
- const result = await runPipeline(config, process.cwd());
51
- console.log(result.success ? 'Done' : 'Failed');
52
- ```
53
-
54
- ## Features
55
-
56
- - **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
57
- - **Driver plugins** -- built-in `claude-code` driver; install `@tagma/driver-codex` or `@tagma/driver-opencode` for other agents
58
- - **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
59
- - **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
60
- - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
61
- - **Middleware** -- enrich prompts before execution (e.g. inject static context)
62
- - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
63
- - **Template expansion** -- reusable task templates with parameterized `use` / `with`
64
-
65
- ## Pipeline YAML Reference
66
-
67
- ```yaml
68
- pipeline:
69
- name: my-pipeline
70
- driver: claude-code # default driver for all tasks
71
- timeout: 30m # pipeline-level timeout
72
- plugins: # load external driver plugins
73
- - "@tagma/driver-codex"
74
- hooks:
75
- pipeline_start: "echo starting"
76
- task_failure: "notify-slack.sh"
77
- tracks:
78
- - id: track-1
79
- name: Track One
80
- model_tier: high # high | medium | low
81
- permissions:
82
- read: true
83
- write: true
84
- execute: false
85
- on_failure: skip_downstream # skip_downstream | stop_all | ignore
86
- tasks:
87
- - id: task-a
88
- name: Do something
89
- prompt: "Your prompt here"
90
- output: ./output/task-a.txt
91
- timeout: 10m
92
- - id: task-b
93
- name: Follow up
94
- prompt: "Continue the work"
95
- continue_from: task-a
96
- depends_on: [task-a]
97
- ```
98
-
99
- ## API
100
-
101
- ### `bootstrapBuiltins()`
102
-
103
- Registers all built-in plugins (claude-code driver, file/manual triggers, completion checks, static-context middleware).
104
-
105
- ### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
106
-
107
- Parses YAML, resolves inheritance, expands templates, and validates the configuration.
108
-
109
- ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
110
-
111
- Executes the pipeline. Returns `{ success, summary, states }`.
112
-
113
- Options:
114
- - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
115
-
116
- ### `loadPlugins(names: string[]): Promise<void>`
117
-
118
- Dynamically loads and registers external plugin packages.
119
-
120
- ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
121
-
122
- Attaches an interactive stdin-based approval handler.
123
-
124
- ### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
125
-
126
- Starts a WebSocket server for remote approval decisions.
127
-
128
- ## Related Packages
129
-
130
- | Package | Description |
131
- |---|---|
132
- | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
133
- | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
134
- | [@tagma/driver-opencode](https://www.npmjs.com/package/@tagma/driver-opencode) | OpenCode CLI driver plugin |
135
- | [@tagma/cli](https://www.npmjs.com/package/@tagma/cli) | CLI runner |
136
-
137
- ## License
138
-
139
- MIT
1
+ # @tagma/sdk
2
+
3
+ A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (Claude Code, Codex, OpenCode) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @tagma/sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ **1. Define a pipeline** (`pipeline.yaml`)
14
+
15
+ ```yaml
16
+ pipeline:
17
+ name: build-and-test
18
+ tracks:
19
+ - id: backend
20
+ name: Backend
21
+ driver: claude-code
22
+ permissions: { read: true, write: true, execute: false }
23
+ tasks:
24
+ - id: implement
25
+ name: Implement feature
26
+ prompt: "Add a /health endpoint to src/server.ts"
27
+ output: ./output/implement.txt
28
+ - id: test
29
+ name: Run tests
30
+ command: "bun test"
31
+ depends_on: [implement]
32
+ ```
33
+
34
+ **2. Run it programmatically**
35
+
36
+ ```ts
37
+ import {
38
+ bootstrapBuiltins,
39
+ loadPipeline,
40
+ runPipeline,
41
+ InMemoryApprovalGateway,
42
+ } from '@tagma/sdk';
43
+
44
+ // Register built-in drivers, triggers, completions
45
+ bootstrapBuiltins();
46
+
47
+ const yaml = await Bun.file('pipeline.yaml').text();
48
+ const config = await loadPipeline(yaml, process.cwd());
49
+
50
+ const result = await runPipeline(config, process.cwd());
51
+ console.log(result.success ? 'Done' : 'Failed');
52
+ ```
53
+
54
+ ## Features
55
+
56
+ - **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
57
+ - **Driver plugins** -- built-in `claude-code` driver; install `@tagma/driver-codex` or `@tagma/driver-opencode` for other agents
58
+ - **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
59
+ - **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
60
+ - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
61
+ - **Middleware** -- enrich prompts before execution (e.g. inject static context)
62
+ - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
63
+ - **Template expansion** -- reusable task templates with parameterized `use` / `with`
64
+
65
+ ## Pipeline YAML Reference
66
+
67
+ ```yaml
68
+ pipeline:
69
+ name: my-pipeline
70
+ driver: claude-code # default driver for all tasks
71
+ timeout: 30m # pipeline-level timeout
72
+ plugins: # load external driver plugins
73
+ - "@tagma/driver-codex"
74
+ hooks:
75
+ pipeline_start: "echo starting"
76
+ task_failure: "notify-slack.sh"
77
+ tracks:
78
+ - id: track-1
79
+ name: Track One
80
+ model_tier: high # high | medium | low
81
+ permissions:
82
+ read: true
83
+ write: true
84
+ execute: false
85
+ on_failure: skip_downstream # skip_downstream | stop_all | ignore
86
+ tasks:
87
+ - id: task-a
88
+ name: Do something
89
+ prompt: "Your prompt here"
90
+ output: ./output/task-a.txt
91
+ timeout: 10m
92
+ - id: task-b
93
+ name: Follow up
94
+ prompt: "Continue the work"
95
+ continue_from: task-a
96
+ depends_on: [task-a]
97
+ ```
98
+
99
+ ## API
100
+
101
+ ### `bootstrapBuiltins()`
102
+
103
+ Registers all built-in plugins (claude-code driver, file/manual triggers, completion checks, static-context middleware).
104
+
105
+ ### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
106
+
107
+ Parses YAML, resolves inheritance, expands templates, and validates the configuration.
108
+
109
+ ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
110
+
111
+ Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
112
+
113
+ Options:
114
+ - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
115
+ - `signal` -- `AbortSignal` to cancel the run externally
116
+ - `onEvent` -- callback for real-time `PipelineEvent` updates (task status changes, pipeline start/end)
117
+ - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/logs/` (default: 20)
118
+
119
+ ### `PipelineRunner`
120
+
121
+ Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
122
+
123
+ ```ts
124
+ const runner = new PipelineRunner(config, workDir);
125
+
126
+ // Subscribe before start — handler is called for every PipelineEvent
127
+ const unsubscribe = runner.subscribe(event => {
128
+ tauriEmit('pipeline_event', { id: runner.instanceId, event });
129
+ });
130
+
131
+ runner.start(); // returns Promise<EngineResult>, idempotent
132
+
133
+ // Cancel from IPC
134
+ runner.abort();
135
+
136
+ // After completion
137
+ const states = runner.getStates(); // ReadonlyMap<taskId, TaskState>
138
+ ```
139
+
140
+ Properties:
141
+ - `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
142
+ - `runId` — engine-assigned run ID, available after the first `pipeline_start` event (`null` until then)
143
+ - `status` — `'idle' | 'running' | 'done' | 'aborted'`
144
+
145
+ ### `loadPlugins(names: string[]): Promise<void>`
146
+
147
+ Dynamically loads and registers external plugin packages.
148
+
149
+ ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
150
+
151
+ Attaches an interactive stdin-based approval handler.
152
+
153
+ ### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
154
+
155
+ Starts a WebSocket server for remote approval decisions.
156
+
157
+ ### Config CRUD (`config-ops`)
158
+
159
+ Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
160
+
161
+ ```ts
162
+ import {
163
+ createEmptyPipeline, setPipelineField,
164
+ upsertTrack, removeTrack, moveTrack, updateTrack,
165
+ upsertTask, removeTask, moveTask, transferTask,
166
+ serializePipeline,
167
+ } from '@tagma/sdk';
168
+
169
+ // Build a config programmatically
170
+ let config = createEmptyPipeline('my-pipeline');
171
+ config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
172
+ config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
173
+
174
+ // Sync back to YAML
175
+ const yaml = serializePipeline(config);
176
+ ```
177
+
178
+ | Function | Description |
179
+ |---|---|
180
+ | `createEmptyPipeline(name)` | Create a minimal pipeline config |
181
+ | `setPipelineField(config, fields)` | Update top-level pipeline fields |
182
+ | `upsertTrack(config, track)` | Insert or replace a track by id |
183
+ | `removeTrack(config, trackId)` | Remove a track |
184
+ | `moveTrack(config, trackId, toIndex)` | Reorder a track |
185
+ | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
186
+ | `upsertTask(config, trackId, task)` | Insert or replace a task |
187
+ | `removeTask(config, trackId, taskId)` | Remove a task |
188
+ | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
189
+ | `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
190
+
191
+ ### `parseYaml(content: string): RawPipelineConfig`
192
+
193
+ 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()`.
194
+
195
+ ### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
196
+
197
+ 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.
198
+
199
+ Use this when you have a programmatically modified resolved config and need to save it back to YAML:
200
+
201
+ ```ts
202
+ // Correct: load → modify resolved config → deresolve → save
203
+ const config = await loadPipeline(yaml, workDir);
204
+ const modified = { ...config, name: 'renamed' };
205
+ const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
206
+
207
+ // Also correct: work entirely in raw space (preferred for visual editors)
208
+ const raw = parseYaml(yaml);
209
+ const updatedRaw = setPipelineField(raw, { name: 'renamed' });
210
+ const savedYaml = serializePipeline(updatedRaw);
211
+ ```
212
+
213
+ ### `validateConfig(config: PipelineConfig): string[]`
214
+
215
+ Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
216
+
217
+ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
218
+
219
+ ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
220
+
221
+ Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
222
+
223
+ Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity, circular dependency detection.
224
+
225
+ Does **not** check plugin registration (plugins may not be loaded at edit time).
226
+
227
+ ```ts
228
+ const errors = validateRaw(draftConfig);
229
+ if (errors.length > 0) {
230
+ errors.forEach(e => highlightNode(e.path, e.message));
231
+ }
232
+ ```
233
+
234
+ ## Related Packages
235
+
236
+ | Package | Description |
237
+ |---|---|
238
+ | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
239
+ | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
240
+ | [@tagma/driver-opencode](https://www.npmjs.com/package/@tagma/driver-opencode) | OpenCode CLI driver plugin |
241
+ | [@tagma/cli](https://www.npmjs.com/package/@tagma/cli) | CLI runner |
242
+
243
+ ## License
244
+
245
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  "plugins/*"
@@ -20,13 +20,13 @@
20
20
  "dependencies": {
21
21
  "js-yaml": "^4.1.0",
22
22
  "chokidar": "^4.0.0",
23
- "@tagma/types": "0.1.2"
23
+ "@tagma/types": "workspace:*"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/js-yaml": "^4.0.9",
27
27
  "bun-types": "latest",
28
28
  "typescript": "^6.0.2",
29
- "@tagma/driver-codex": "0.1.2",
30
- "@tagma/driver-opencode": "0.1.2"
29
+ "@tagma/driver-codex": "workspace:*",
30
+ "@tagma/driver-opencode": "workspace:*"
31
31
  }
32
32
  }
@@ -1,117 +1,117 @@
1
- import * as readline from 'readline';
2
- import type { ApprovalGateway, ApprovalRequest } from '../approval';
3
-
4
- // ═══ CLI Stdin Adapter ═══
5
- //
6
- // Subscribes to the gateway's 'requested' events, prompts the user on stdout,
7
- // reads a line from stdin, and calls gateway.resolve(). Handles at most one
8
- // prompt at a time — additional requests queue up.
9
-
10
- export interface StdinApprovalAdapter {
11
- readonly detach: () => void;
12
- }
13
-
14
- export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
15
- const queue: ApprovalRequest[] = [];
16
- let processing = false;
17
- let rl: readline.Interface | null = null;
18
-
19
- function ensureReadline(): readline.Interface {
20
- if (!rl) {
21
- rl = readline.createInterface({ input: process.stdin, terminal: false });
22
- }
23
- return rl;
24
- }
25
-
26
- function readOneLine(): Promise<string> {
27
- return new Promise((resolvePromise) => {
28
- const reader = ensureReadline();
29
- const handler = (line: string): void => {
30
- reader.off('line', handler);
31
- resolvePromise(line);
32
- };
33
- reader.on('line', handler);
34
- });
35
- }
36
-
37
- async function processNext(): Promise<void> {
38
- if (processing) return;
39
- processing = true;
40
- try {
41
- while (queue.length > 0) {
42
- const req = queue.shift()!;
43
- // If the request was already resolved by another path while queued, skip it.
44
- if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
-
46
- const optionsStr = req.options.join(' / ');
47
- process.stdout.write(
48
- `\n[APPROVAL REQUIRED] ${req.message}\n` +
49
- ` id: ${req.id}\n` +
50
- ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
51
- ` options: ${optionsStr}\n` +
52
- ` > `,
53
- );
54
-
55
- const input = (await readOneLine()).trim().toLowerCase();
56
-
57
- const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
58
- const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
59
- const matchedOption = req.options.find((o) => o.toLowerCase() === input);
60
-
61
- if (matchedOption) {
62
- const isReject = rejectAliases.has(matchedOption.toLowerCase());
63
- gateway.resolve(req.id, {
64
- outcome: isReject ? 'rejected' : 'approved',
65
- choice: matchedOption,
66
- actor: 'cli',
67
- });
68
- } else if (approveAliases.has(input)) {
69
- gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
70
- } else if (rejectAliases.has(input)) {
71
- gateway.resolve(req.id, {
72
- outcome: 'rejected',
73
- choice: input,
74
- actor: 'cli',
75
- reason: 'user rejected via CLI',
76
- });
77
- } else {
78
- process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
79
- gateway.resolve(req.id, {
80
- outcome: 'rejected',
81
- actor: 'cli',
82
- reason: `unrecognized CLI input: ${input}`,
83
- });
84
- }
85
- }
86
- } finally {
87
- processing = false;
88
- }
89
- }
90
-
91
- const unsubscribe = gateway.subscribe((event) => {
92
- switch (event.type) {
93
- case 'requested':
94
- queue.push(event.request);
95
- void processNext();
96
- return;
97
- case 'resolved':
98
- case 'expired':
99
- case 'aborted': {
100
- // Drop from queue if it's still waiting its turn.
101
- const idx = queue.findIndex((r) => r.id === event.request.id);
102
- if (idx >= 0) queue.splice(idx, 1);
103
- return;
104
- }
105
- }
106
- });
107
-
108
- return {
109
- detach: () => {
110
- unsubscribe();
111
- if (rl) {
112
- rl.close();
113
- rl = null;
114
- }
115
- },
116
- };
117
- }
1
+ import * as readline from 'readline';
2
+ import type { ApprovalGateway, ApprovalRequest } from '../approval';
3
+
4
+ // ═══ CLI Stdin Adapter ═══
5
+ //
6
+ // Subscribes to the gateway's 'requested' events, prompts the user on stdout,
7
+ // reads a line from stdin, and calls gateway.resolve(). Handles at most one
8
+ // prompt at a time — additional requests queue up.
9
+
10
+ export interface StdinApprovalAdapter {
11
+ readonly detach: () => void;
12
+ }
13
+
14
+ export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
15
+ const queue: ApprovalRequest[] = [];
16
+ let processing = false;
17
+ let rl: readline.Interface | null = null;
18
+
19
+ function ensureReadline(): readline.Interface {
20
+ if (!rl) {
21
+ rl = readline.createInterface({ input: process.stdin, terminal: false });
22
+ }
23
+ return rl;
24
+ }
25
+
26
+ function readOneLine(): Promise<string> {
27
+ return new Promise((resolvePromise) => {
28
+ const reader = ensureReadline();
29
+ const handler = (line: string): void => {
30
+ reader.off('line', handler);
31
+ resolvePromise(line);
32
+ };
33
+ reader.on('line', handler);
34
+ });
35
+ }
36
+
37
+ async function processNext(): Promise<void> {
38
+ if (processing) return;
39
+ processing = true;
40
+ try {
41
+ while (queue.length > 0) {
42
+ const req = queue.shift()!;
43
+ // If the request was already resolved by another path while queued, skip it.
44
+ if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
+
46
+ const optionsStr = req.options.join(' / ');
47
+ process.stdout.write(
48
+ `\n[APPROVAL REQUIRED] ${req.message}\n` +
49
+ ` id: ${req.id}\n` +
50
+ ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
51
+ ` options: ${optionsStr}\n` +
52
+ ` > `,
53
+ );
54
+
55
+ const input = (await readOneLine()).trim().toLowerCase();
56
+
57
+ const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
58
+ const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
59
+ const matchedOption = req.options.find((o) => o.toLowerCase() === input);
60
+
61
+ if (matchedOption) {
62
+ const isReject = rejectAliases.has(matchedOption.toLowerCase());
63
+ gateway.resolve(req.id, {
64
+ outcome: isReject ? 'rejected' : 'approved',
65
+ choice: matchedOption,
66
+ actor: 'cli',
67
+ });
68
+ } else if (approveAliases.has(input)) {
69
+ gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
70
+ } else if (rejectAliases.has(input)) {
71
+ gateway.resolve(req.id, {
72
+ outcome: 'rejected',
73
+ choice: input,
74
+ actor: 'cli',
75
+ reason: 'user rejected via CLI',
76
+ });
77
+ } else {
78
+ process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
79
+ gateway.resolve(req.id, {
80
+ outcome: 'rejected',
81
+ actor: 'cli',
82
+ reason: `unrecognized CLI input: ${input}`,
83
+ });
84
+ }
85
+ }
86
+ } finally {
87
+ processing = false;
88
+ }
89
+ }
90
+
91
+ const unsubscribe = gateway.subscribe((event) => {
92
+ switch (event.type) {
93
+ case 'requested':
94
+ queue.push(event.request);
95
+ void processNext();
96
+ return;
97
+ case 'resolved':
98
+ case 'expired':
99
+ case 'aborted': {
100
+ // Drop from queue if it's still waiting its turn.
101
+ const idx = queue.findIndex((r) => r.id === event.request.id);
102
+ if (idx >= 0) queue.splice(idx, 1);
103
+ return;
104
+ }
105
+ }
106
+ });
107
+
108
+ return {
109
+ detach: () => {
110
+ unsubscribe();
111
+ if (rl) {
112
+ rl.close();
113
+ rl = null;
114
+ }
115
+ },
116
+ };
117
+ }