@ryanfw/prompt-orchestration-pipeline 1.2.12 → 1.3.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.
Files changed (93) hide show
  1. package/README.md +34 -0
  2. package/docs/http-api.md +240 -0
  3. package/docs/pop-task-guide.md +120 -1
  4. package/package.json +7 -2
  5. package/src/cli/__tests__/index.test.ts +30 -0
  6. package/src/cli/index.ts +26 -5
  7. package/src/config/__tests__/models.test.ts +18 -4
  8. package/src/config/__tests__/statuses.test.ts +49 -2
  9. package/src/config/models.ts +12 -1
  10. package/src/config/statuses.ts +23 -2
  11. package/src/core/__tests__/agent-step.test.ts +446 -0
  12. package/src/core/__tests__/control.test.ts +241 -0
  13. package/src/core/__tests__/pipeline-runner.test.ts +214 -0
  14. package/src/core/__tests__/run-events.test.ts +63 -0
  15. package/src/core/__tests__/task-runner.test.ts +48 -0
  16. package/src/core/__tests__/validation.test.ts +168 -0
  17. package/src/core/agent-step.ts +210 -0
  18. package/src/core/agent-types.ts +58 -0
  19. package/src/core/control.ts +299 -0
  20. package/src/core/job-concurrency.ts +1 -1
  21. package/src/core/object-utils.ts +9 -0
  22. package/src/core/orchestrator.ts +27 -6
  23. package/src/core/pipeline-definition.ts +55 -0
  24. package/src/core/pipeline-runner.ts +587 -67
  25. package/src/core/run-events.ts +19 -0
  26. package/src/core/status-writer.ts +15 -1
  27. package/src/core/task-runner.ts +19 -0
  28. package/src/core/validation.ts +243 -18
  29. package/src/harness/__tests__/discovery.test.ts +183 -0
  30. package/src/harness/__tests__/mcp-io-server.test.ts +178 -0
  31. package/src/harness/__tests__/subprocess.test.ts +61 -0
  32. package/src/harness/discovery.ts +99 -0
  33. package/src/harness/index.ts +22 -0
  34. package/src/harness/mcp-io-server.ts +166 -0
  35. package/src/harness/subprocess.ts +83 -0
  36. package/src/llm/__tests__/index.test.ts +202 -0
  37. package/src/llm/index.ts +7 -0
  38. package/src/providers/__tests__/base.test.ts +6 -0
  39. package/src/providers/__tests__/opencode.test.ts +1359 -0
  40. package/src/providers/__tests__/types.test.ts +79 -1
  41. package/src/providers/base.ts +1 -1
  42. package/src/providers/opencode.ts +563 -0
  43. package/src/providers/types.ts +57 -0
  44. package/src/ui/client/__tests__/api.test.ts +19 -1
  45. package/src/ui/client/__tests__/job-adapter.test.ts +55 -0
  46. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +40 -0
  47. package/src/ui/client/adapters/job-adapter.ts +33 -3
  48. package/src/ui/client/api.ts +14 -0
  49. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +47 -3
  50. package/src/ui/client/hooks/useJobListWithUpdates.ts +1 -0
  51. package/src/ui/client/types.ts +25 -3
  52. package/src/ui/components/DAGGrid.tsx +32 -11
  53. package/src/ui/components/JobCard.tsx +17 -2
  54. package/src/ui/components/JobDetail.tsx +93 -9
  55. package/src/ui/components/JobTable.tsx +1 -0
  56. package/src/ui/components/PipelineDAGGrid.tsx +17 -2
  57. package/src/ui/components/__tests__/DAGGrid.test.tsx +37 -0
  58. package/src/ui/components/__tests__/JobCard.test.tsx +47 -0
  59. package/src/ui/components/__tests__/JobDetail.test.tsx +80 -3
  60. package/src/ui/components/__tests__/JobTable.test.tsx +10 -0
  61. package/src/ui/components/__tests__/types.test.ts +8 -3
  62. package/src/ui/components/types.ts +13 -1
  63. package/src/ui/dist/assets/{index-HrBsHfx3.js → index-CbS3OsW7.js} +579 -168
  64. package/src/ui/dist/assets/index-CbS3OsW7.js.map +1 -0
  65. package/src/ui/dist/assets/style-BUFg3Sth.css +2 -0
  66. package/src/ui/dist/index.html +2 -2
  67. package/src/ui/embedded-assets.js +6 -6
  68. package/src/ui/pages/Code.tsx +232 -3
  69. package/src/ui/server/__tests__/cors.test.ts +148 -0
  70. package/src/ui/server/__tests__/gate-endpoints.test.ts +210 -0
  71. package/src/ui/server/__tests__/http-api-doc.test.ts +48 -0
  72. package/src/ui/server/__tests__/index.test.ts +29 -1
  73. package/src/ui/server/__tests__/job-control-endpoints.test.ts +273 -1
  74. package/src/ui/server/__tests__/job-endpoints.test.ts +124 -0
  75. package/src/ui/server/__tests__/router.test.ts +224 -2
  76. package/src/ui/server/cors.ts +83 -0
  77. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +29 -0
  78. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  79. package/src/ui/server/endpoints/gate-endpoints.ts +124 -0
  80. package/src/ui/server/endpoints/job-control-endpoints.ts +98 -11
  81. package/src/ui/server/endpoints/job-endpoints.ts +28 -12
  82. package/src/ui/server/endpoints/meta-endpoint.ts +11 -0
  83. package/src/ui/server/index.ts +6 -1
  84. package/src/ui/server/router.ts +49 -3
  85. package/src/ui/state/__tests__/types.test.ts +1 -0
  86. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +15 -0
  87. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +56 -0
  88. package/src/ui/state/transformers/list-transformer.ts +3 -0
  89. package/src/ui/state/transformers/status-transformer.ts +32 -4
  90. package/src/ui/state/types.ts +20 -4
  91. package/src/utils/jobs.ts +1 -1
  92. package/src/ui/dist/assets/index-HrBsHfx3.js.map +0 -1
  93. package/src/ui/dist/assets/style-BKG0bHu-.css +0 -2
package/README.md CHANGED
@@ -24,11 +24,13 @@ This framework is designed for AI Engineers and Systems Integrators who need to
24
24
  Running long-duration AI tasks locally can be fragile. A single script crash or API timeout can waste hours of execution and dollars in token costs.
25
25
  * **Process Isolation**: Every pipeline runs in its own dedicated child process. If one agent crashes, your orchestrator stays alive.
26
26
  * **Resumability**: Pause, stop, and resume jobs from any specific task. Fix a bug in step 5 and restart exactly where you left off.
27
+ * **Run Control**: Tasks can pause for human approval, skip downstream work, or add follow-up tasks to the current run.
27
28
  * **Atomic State**: Every stage transition is saved to disk instantly. You never lose progress.
28
29
 
29
30
  ### 2. Gain Radical Observability
30
31
  "Black box" agents are impossible to debug. POP provides deep visibility into the "thought process" of your pipelines.
31
32
  * **Real-Time Dashboard**: Watch jobs progress stage-by-stage via a built-in UI (Server-Sent Events).
33
+ * **Gate Decisions**: Approve or reject waiting jobs directly from the job detail view.
32
34
  * **Granular Logging**: Every input, output, and internal thought is captured in dedicated log files.
33
35
  * **Cost Tracking**: See exact token usage and cost breakdown for every task and model call.
34
36
 
@@ -48,6 +50,7 @@ Switch models globally or per-task without rewriting your logic.
48
50
  * **Moonshot** (Kimi)
49
51
  * **Zhipu** (GLM-4)
50
52
  * **Claude Code** (CLI integration)
53
+ * **CLI Agents**: Tasks can also drive tool-using CLI coding agents (Claude, Codex, OpenCode) via the injected `runAgent()` helper — for file-aware, multi-turn work alongside single LLM calls. See the [Task Development Guide](docs/pop-task-guide.md#agent-api).
51
54
 
52
55
  ---
53
56
 
@@ -79,6 +82,7 @@ The system comprises three main runtime containers:
79
82
  Inside the **Pipeline Runner**, the logic is structured into:
80
83
 
81
84
  * **Task Runner**: The engine that drives a specific task (e.g., "Research") through the standardized lifecycle.
85
+ * **Run Control Contract**: A task can write `tasks/{taskName}/control.json` to append tasks, skip pending tasks, or create an approval gate after it succeeds.
82
86
  * **LLM Layer**: A unified abstraction for all model providers, handling retries, cost calculation, and normalization.
83
87
  * **Symlink Bridge**: A specialized component that ensures every task has deterministic access to `node_modules` and shared utilities, regardless of where it is defined on disk.
84
88
  * **Status Writer**: An atomic file-writer that updates `tasks-status.json` safely, preventing data corruption.
@@ -175,3 +179,33 @@ Drop a JSON file into `pipelines/pipeline-data/pending/`:
175
179
  ```
176
180
 
177
181
  The Orchestrator will pick it up, move it to `current/`, and start processing.
182
+
183
+ ---
184
+
185
+ ## Building a Custom Frontend
186
+
187
+ The orchestrator exposes a documented HTTP/SSE API at `http://localhost:<port>/api/*` that any frontend can consume. See **[HTTP/SSE Protocol Reference](docs/http-api.md)** for the full contract.
188
+
189
+ Custom frontends should run against a configured fixed port, usually the default `4000` or an explicit `--port` value. Treat bind failures or port conflicts as startup failures; the protocol does not provide dynamic port discovery.
190
+
191
+ ### Integration Modes
192
+
193
+ **Subprocess mode** (recommended): Run the orchestrator as a child process via `pipeline-orchestrator start`. The orchestrator runs in its own process — if it crashes, your application stays alive. This is the default and recommended approach.
194
+
195
+ **Programmatic mode**: Import `startServer` or `startOrchestrator` directly to embed the server in your process. This is simpler but forfeits process isolation — a crash in the orchestrator will take down your host process.
196
+
197
+ ### Cross-Origin Access
198
+
199
+ By default, the server only accepts same-origin requests. To allow a custom frontend on a different origin:
200
+
201
+ ```bash
202
+ pipeline-orchestrator start --cors-origins "https://my-app.example,views://mainview"
203
+ ```
204
+
205
+ For desktop webviews that report `Origin: null`, add `--cors-allow-null-origin`. Prefer allowlisting the concrete origin (e.g. `views://mainview`) when possible.
206
+
207
+ ### Protocol Versioning
208
+
209
+ The API follows a client-robustness contract: clients must ignore unknown JSON fields and unknown SSE event types. This means additive changes (new routes, new event types, new fields) are backward-compatible. Breaking changes (removing or renaming fields, changing types) require a `protocolVersion` bump and are documented in `docs/http-api.md`.
210
+
211
+ Capability negotiation is not built. Custom frontends should use the documented protocol and `GET /api/meta` instead of expecting negotiated feature flags or per-client capability exchange.
@@ -0,0 +1,240 @@
1
+ # HTTP/SSE Protocol Reference
2
+
3
+ This document is the authoritative reference for the Prompt Orchestration Pipeline HTTP API and Server-Sent Events stream. A consumer should be able to build a client from this document alone.
4
+
5
+ ## Transport
6
+
7
+ - **Protocol**: HTTP/1.1 + Server-Sent Events (SSE). The API does not use WebSocket.
8
+ - **Default port**: `4000` (configurable via `--port`).
9
+ - **Port contract**: Custom frontends should connect to a configured fixed port. Bind failures or port conflicts are startup failures; the protocol does not provide dynamic port discovery.
10
+ - **Bind address**: `127.0.0.1` (loopback only). The server does not listen on non-loopback interfaces.
11
+ - **Trust model**: localhost. The `Host` header must resolve to a loopback address (`localhost`, `127.0.0.1`, or `[::1]`). Requests with a non-loopback `Host` are rejected with `403 forbidden_host`.
12
+ - **CORS**: Opt-in via `--cors-origins <comma-separated-origins>` and `--cors-allow-null-origin`. When no origins are configured, no `Access-Control-*` headers are emitted. Cross-origin mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) to `/api/*` are rejected before dispatch when the origin is not allowed.
13
+ - **Content type**: All JSON responses use `Content-Type: application/json`. SSE streams use `Content-Type: text/event-stream`.
14
+
15
+ ## Response Envelope
16
+
17
+ Every JSON response uses the shape:
18
+
19
+ ```json
20
+ { "ok": true, "data": { ... } }
21
+ ```
22
+
23
+ or on error:
24
+
25
+ ```json
26
+ { "ok": false, "code": "<error_code>", "message": "<human-readable message>" }
27
+ ```
28
+
29
+ ### Error Codes
30
+
31
+ | Code | HTTP Status | Meaning |
32
+ |------|-------------|---------|
33
+ | `job_not_found` / `JOB_NOT_FOUND` / `NOT_FOUND` | 404 | Job or resource does not exist |
34
+ | `job_running` / `JOB_RUNNING` | 409 | Job is currently executing |
35
+ | `conflict` / `BAD_REQUEST` (409) | 409 | Action conflicts with current state |
36
+ | `spawn_failed` / `SPAWN_FAILED` | 500 | Failed to spawn a pipeline runner |
37
+ | `task_not_found` / `TASK_NOT_FOUND` | 404 | Task does not exist in the job |
38
+ | `task_not_pending` / `TASK_NOT_PENDING` | 422 | Task is not in a pending state |
39
+ | `dependencies_not_satisfied` / `DEPENDENCIES_NOT_SATISFIED` | 412 | Task dependencies are incomplete |
40
+ | `unsupported_lifecycle` / `UNSUPPORTED_LIFECYCLE` | 501 | Lifecycle action not supported |
41
+ | `concurrency_limit_reached` | 409 | Max concurrent job slots occupied |
42
+ | `unknown_error` | 500 | Unspecified server error |
43
+ | `network_error` | — | Client-side network failure (never from server) |
44
+ | `malformed_response` | — | Client-side parse failure (never from server) |
45
+ | `forbidden_host` | 403 | `Host` header is not loopback |
46
+ | `forbidden_origin` | 403 | Cross-origin mutation rejected |
47
+ | `status_unavailable` | 500 | Job status file unreadable |
48
+ | `no_pending_gate` | 409 | Job has no pending gate decision |
49
+ | `FS_ERROR` | 500 | Filesystem operation failed |
50
+ | `BAD_REQUEST` | 400 | Malformed request body |
51
+ | `NOT_FOUND` | 404 | Route or resource not found |
52
+
53
+ ## Routes
54
+
55
+ All routes are prefixed with `/api`. The server also serves a bundled SPA from `/` for non-API paths.
56
+
57
+ ### Jobs
58
+
59
+ | Method | Path | Request Body | Response |
60
+ |--------|------|-------------|----------|
61
+ | `GET` | `/api/jobs` | — | `{ ok, data: JobSummary[] }` |
62
+ | `GET` | `/api/jobs/:jobId` | — | `{ ok, data: JobDetail }` |
63
+ | `POST` | `/api/jobs/:jobId/gate` | `{ action: "approve" \| "reject", note?: string }` | `{ ok, jobId, action, spawned }` (202) |
64
+ | `POST` | `/api/jobs/:jobId/restart` | `{ fromTask?: string, singleTask?: boolean, continueAfter?: boolean, options?: { clearTokenUsage?: boolean } }` | `{ ok, message? }` |
65
+ | `POST` | `/api/jobs/:jobId/stop` | — | `{ ok, message? }` |
66
+ | `POST` | `/api/jobs/:jobId/rescan` | — | `{ ok, message? }` |
67
+ | `POST` | `/api/jobs/:jobId/tasks/:taskId/start` | — | `{ ok, message? }` |
68
+
69
+ ### Job Files
70
+
71
+ | Method | Path | Query Params | Response |
72
+ |--------|------|-------------|----------|
73
+ | `GET` | `/api/jobs/:jobId/tasks/:taskId/files` | `?type=artifacts\|logs\|tmp` (default: `artifacts`) | `{ ok, data: string[] }` |
74
+ | `GET` | `/api/jobs/:jobId/tasks/:taskId/file` | `?type=...&filename=...` | `{ ok, data: string, mime: string }` |
75
+
76
+ ### Pipelines
77
+
78
+ | Method | Path | Request Body | Response |
79
+ |--------|------|-------------|----------|
80
+ | `GET` | `/api/pipelines` | — | `{ ok, data: { slug, name, description }[] }` |
81
+ | `GET` | `/api/pipelines/:slug` | — | `{ ok, data: PipelineDetail }` |
82
+ | `POST` | `/api/pipelines` | `{ name: string, description?: string }` | `{ ok, data: { slug, name, description } }` (201) |
83
+ | `GET` | `/api/pipelines/:slug/artifacts` | — | `{ ok, data: Artifact[] }` |
84
+ | `GET` | `/api/pipelines/:slug/tasks/:taskId/analysis` | — | `{ ok, data: AnalysisResult }` |
85
+ | `GET` | `/api/pipelines/:slug/schemas/:filename` | — | `{ ok, data: string }` (schema content) |
86
+
87
+ ### Analysis and Task Planning
88
+
89
+ | Method | Path | Request Body | Response |
90
+ |--------|------|-------------|----------|
91
+ | `POST` | `/api/pipelines/:slug/analyze` | — | SSE stream: `started` → `complete` |
92
+ | `POST` | `/api/ai/task-plan` | — | SSE stream: `started` → `complete` |
93
+
94
+ These endpoints return `Content-Type: text/event-stream` (route-local SSE). See [Route-Local SSE Streams](#route-local-sse-streams).
95
+
96
+ ### Tasks
97
+
98
+ | Method | Path | Request Body | Response |
99
+ |--------|------|-------------|----------|
100
+ | `POST` | `/api/tasks/create` | `{ slug: string, taskId: string, content: string }` | `{ ok, data: { slug, taskId } }` (201) |
101
+
102
+ ### Upload
103
+
104
+ | Method | Path | Request Body | Response |
105
+ |--------|------|-------------|----------|
106
+ | `POST` | `/api/upload/seed` | JSON seed object or `multipart/form-data` (with optional `.zip`) | `{ ok, data: { jobId } }` (201) |
107
+
108
+ ### System
109
+
110
+ | Method | Path | Response |
111
+ |--------|------|----------|
112
+ | `GET` | `/api/state` | `{ ok, data: StateSnapshot }` |
113
+ | `GET` | `/api/meta` | `{ ok, data: { name, version, protocolVersion } }` |
114
+ | `GET` | `/api/concurrency` | `{ ok, data: ConcurrencyStatus }` |
115
+
116
+ #### `/api/meta` Response Fields
117
+
118
+ | Field | Type | Description |
119
+ |-------|------|-------------|
120
+ | `name` | `string` | Package name from `package.json` |
121
+ | `version` | `string` | Package version from `package.json` |
122
+ | `protocolVersion` | `number` | Protocol version integer; bumped only on breaking changes (see [Protocol Versioning](#protocol-versioning)) |
123
+
124
+ #### `/api/concurrency` Response Fields
125
+
126
+ | Field | Type | Description |
127
+ |-------|------|-------------|
128
+ | `limit` | `number` | Max concurrent jobs |
129
+ | `runningCount` | `number` | Currently running jobs |
130
+ | `availableSlots` | `number` | Remaining slot capacity |
131
+ | `queuedCount` | `number` | Queued jobs waiting for a slot |
132
+ | `activeJobs` | `Array<{ jobId, pid, acquiredAt, source }>` | Jobs occupying slots |
133
+ | `queuedJobs` | `Array<{ jobId, queuedAt, name, pipeline }>` | Jobs waiting in queue |
134
+ | `staleSlots` | `Array<{ jobId, reason }>` | Slots that appear stale |
135
+
136
+ ### SSE Stream (Global)
137
+
138
+ | Method | Path | Query Params | Response |
139
+ |--------|------|-------------|----------|
140
+ | `GET` | `/api/events` | `?jobId=<id>` (optional filter) | SSE stream |
141
+ | `GET` | `/api/sse` | `?jobId=<id>` (optional filter) | SSE stream (legacy alias) |
142
+
143
+ ## SSE Event Stream
144
+
145
+ ### Connection
146
+
147
+ Connect to `GET /api/events` (or the legacy `GET /api/sse`). The server responds with:
148
+
149
+ ```
150
+ Content-Type: text/event-stream
151
+ Cache-Control: no-cache
152
+ Connection: keep-alive
153
+ ```
154
+
155
+ On connect, the server sends an initial comment: `: connected\n\n`.
156
+
157
+ ### Keep-Alive
158
+
159
+ The server sends a keep-alive comment every **8 seconds**:
160
+
161
+ ```
162
+ : keep-alive
163
+ ```
164
+
165
+ Clients should treat a gap longer than the keep-alive interval as a potential disconnect.
166
+
167
+ ### Event Types
168
+
169
+ | Event | Data Shape | Description |
170
+ |-------|-----------|-------------|
171
+ | `state:change` | `{ jobId, changeType, ... }` | A job's state has changed |
172
+ | `state:summary` | `{ changeCount }` | Summary of accumulated state changes |
173
+ | `job:created` | `JobSummary` | A new job was detected |
174
+ | `job:updated` | `JobSummary` | An existing job was updated |
175
+ | `heartbeat` | `{ ok, timestamp }` | Application-level heartbeat (distinct from the keep-alive comment) |
176
+
177
+ ### Job Filtering
178
+
179
+ Pass `?jobId=<id>` to receive only events whose data contains a matching `jobId` field. Without the parameter, all events are received.
180
+
181
+ ### SSE Framing
182
+
183
+ Each event follows the standard SSE format:
184
+
185
+ ```
186
+ event: <type>
187
+ data: <JSON>
188
+
189
+ ```
190
+
191
+ Events are separated by a blank line (`\n\n`).
192
+
193
+ ### Route-Local SSE Streams
194
+
195
+ Two endpoints return route-local SSE streams (not from the global `/api/events` stream):
196
+
197
+ **`POST /api/pipelines/:slug/analyze`**
198
+
199
+ ```
200
+ event: started
201
+ data: {"slug":"<slug>"}
202
+
203
+ event: complete
204
+ data: {"slug":"<slug>"}
205
+
206
+ ```
207
+
208
+ Returns `409` if an analysis lock is already held for the pipeline.
209
+
210
+ **`POST /api/ai/task-plan`**
211
+
212
+ ```
213
+ event: started
214
+ data: {"ok":true,"message":"task planning is not implemented in TypeScript yet"}
215
+
216
+ event: complete
217
+ data: {"ok":true}
218
+
219
+ ```
220
+
221
+ ## Client Robustness Rules
222
+
223
+ 1. **Ignore unknown JSON fields.** When parsing a JSON response, ignore any fields not documented here. New fields may be added in minor versions.
224
+ 2. **Ignore unknown SSE event types.** When processing the SSE stream, ignore any event type not documented here. New event types may be added in minor versions.
225
+
226
+ Capability negotiation is not built into this protocol. Clients discover compatibility through the documented contract and `GET /api/meta`, not through negotiated feature flags or per-client capability exchange.
227
+
228
+ ## SSE Event Evolution
229
+
230
+ - **Adding a new event type** is a **minor** change (given the robustness rule above).
231
+ - **Changing an existing event's payload shape** (removing or renaming a field, changing a field's type) is a **breaking** change.
232
+
233
+ ## Protocol Versioning
234
+
235
+ The `protocolVersion` integer returned by `GET /api/meta` is the protocol's semver indicator:
236
+
237
+ - It is **bumped** only when `docs/http-api.md` is updated for a breaking protocol change.
238
+ - It is **not** bumped for additive changes (new routes, new event types, new response fields) because the robustness rules make those backward-compatible.
239
+
240
+ The version is defined in `src/ui/server/endpoints/meta-endpoint.ts` as `PROTOCOL_VERSION`.
@@ -192,6 +192,49 @@ const response = await llm.deepseek.chat({
192
192
 
193
193
  ---
194
194
 
195
+ ## Agent API
196
+
197
+ Available via the `runAgent` function passed to stages. It runs a CLI coding
198
+ agent (the harness adapter) from inside a standard JavaScript task — the same
199
+ machinery behind pipeline `agent:` entries, but callable mid-task with a prompt
200
+ you build programmatically from upstream data.
201
+
202
+ ```js
203
+ export const inference = async ({ runAgent, io, data, flags }) => {
204
+ const result = await runAgent({
205
+ harness: "claude", // "claude" | "codex" | "opencode"
206
+ prompt: "Read 'context.md', then write a summary to 'summary.md'.",
207
+ // model?: string // optional, passed through to the CLI
208
+ // io?: boolean // default true: bridge POP read/write artifacts
209
+ // timeoutMs?: number // optional wall-clock cap
210
+ // captureDiff?: boolean // capture a git diff as 'agent.patch'
211
+ });
212
+
213
+ if (!result.ok) {
214
+ throw new Error(`Agent failed: ${result.error}`);
215
+ }
216
+
217
+ // result: { ok, finalMessage, artifactsWritten, usage?, costUsd?, sessionId? }
218
+ const summary = await io.readArtifact("summary.md");
219
+ return { output: { summary }, flags };
220
+ };
221
+ ```
222
+
223
+ By default (`io` is `true`) the agent shares the task's file I/O: it can call the
224
+ `read_artifact` / `write_artifact` tools to read and write the same artifacts the
225
+ task sees, and its `agent-result.md` is written automatically. Token usage and
226
+ cost flow into the job status like any other LLM call.
227
+
228
+ **`runAgent` vs `llm`**: use `llm.<provider>.chat()` for a single request/response
229
+ LLM call; use `runAgent()` when you need a tool-using CLI agent that reads and
230
+ writes files over multiple turns.
231
+
232
+ **`runAgent` vs an `agent:` pipeline entry**: an `agent:` entry takes a static
233
+ prompt from `pipeline.json`. `runAgent()` lets a JavaScript task compose the
234
+ prompt from seed/stage data and post-process the result in later stages.
235
+
236
+ ---
237
+
195
238
  ## Validation API
196
239
 
197
240
  Available via `validators` object in stages that need schema validation.
@@ -236,6 +279,82 @@ Pipeline jobs start from a seed file in `pending/`:
236
279
 
237
280
  ---
238
281
 
282
+ ## Pipeline Task Entries
283
+
284
+ Pipeline definitions can use simple string entries or object entries. String entries are still the shortest form:
285
+
286
+ ```json
287
+ {
288
+ "tasks": ["research", "analysis", "synthesis"]
289
+ }
290
+ ```
291
+
292
+ Use object entries when multiple run steps should share one task implementation, when a step needs per-entry config, or when a step should pause for review after it succeeds:
293
+
294
+ ```json
295
+ {
296
+ "tasks": [
297
+ "plan",
298
+ { "name": "implement-step-1", "task": "implementation-step", "config": { "step": 1 } },
299
+ { "name": "review", "gate": { "message": "Approve review before merge" } }
300
+ ],
301
+ "taskConfig": {
302
+ "implement-step-1": { "maxAttempts": 2 }
303
+ }
304
+ }
305
+ ```
306
+
307
+ Object entry fields:
308
+
309
+ | Field | Required | Purpose |
310
+ |-------|----------|---------|
311
+ | `name` | Yes | Unique task name for this run. |
312
+ | `task` | No | Task registry key. Defaults to `name`. |
313
+ | `config` | No | Per-entry config merged over `taskConfig[name]`; entry config wins on conflicts. |
314
+ | `gate` | No | `true` or `{ "message": "...", "artifacts": ["..."] }` to pause after this task succeeds. |
315
+
316
+ ---
317
+
318
+ ## Run Control Files
319
+
320
+ A task can control the rest of its run by writing `control.json` in its task directory before the stage lifecycle returns success. Use `io.getTaskDir()` to locate the directory:
321
+
322
+ ```js
323
+ export const integration = async ({ io, flags }) => {
324
+ await Bun.write(`${io.getTaskDir()}/control.json`, JSON.stringify({
325
+ patch: {
326
+ add: [
327
+ { name: "implement-step-2", task: "implementation-step", config: { step: 2 } }
328
+ ]
329
+ },
330
+ skip: [
331
+ { task: "optional-review", reason: "No review required for this run" }
332
+ ],
333
+ pause: {
334
+ message: "Approve generated implementation plan",
335
+ artifacts: ["output.json"]
336
+ }
337
+ }, null, 2));
338
+
339
+ return { output: {}, flags };
340
+ };
341
+ ```
342
+
343
+ Supported directives:
344
+
345
+ | Directive | Purpose |
346
+ |-----------|---------|
347
+ | `patch.add` | Adds new task entries to the current run's per-run `pipeline.json`. |
348
+ | `patch.insertAfter` | Optional insertion point; defaults to the task that wrote the control file. |
349
+ | `skip` | Marks later pending tasks as `skipped`; skipped tasks count as dependency-satisfied. |
350
+ | `pause` | Puts the job in `waiting` and shows Approve gate / Reject gate controls in the UI. |
351
+
352
+ Validation is strict. Invalid JSON, duplicate added names, unknown task registry keys, invalid skip targets, or invalid insertion points fail the emitting task with `ControlValidationError`. These failures happen after task execution succeeds and do not burn task retry attempts.
353
+
354
+ Run mutations are durable but not event-sourced. The source of truth is `tasks-status.json` plus the per-run `pipeline.json`; `events.jsonl` is an append-only audit trail.
355
+
356
+ ---
357
+
239
358
  ## Context Object Reference
240
359
 
241
360
  Each stage receives:
@@ -244,6 +363,7 @@ Each stage receives:
244
363
  {
245
364
  io, // File I/O (may be null)
246
365
  llm, // LLM client
366
+ runAgent, // Run a CLI agent harness (see Agent API)
247
367
  validators, // { validateWithSchema }
248
368
  flags, // Control flags
249
369
  meta: { taskName, workDir, jobId },
@@ -316,4 +436,3 @@ Each stage receives:
316
436
  2. Return `{ output, flags }` from every stage
317
437
  3. Custom helper functions are valid JavaScript but will not be called by the pipeline—only use them if called from within a valid stage
318
438
  4. Most simple tasks need only: `ingestion` → `promptTemplating` → `inference`
319
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.2.12",
3
+ "version": "1.3.1",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server/index.ts",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "src",
12
12
  "docs/pop-task-guide.md",
13
+ "docs/http-api.md",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
@@ -68,7 +69,11 @@
68
69
  "react-syntax-highlighter": "^15.6.1",
69
70
  "rehype-highlight": "^7.0.2",
70
71
  "remark-gfm": "^4.0.1",
71
- "tslib": "^2.8.1"
72
+ "tslib": "^2.8.1",
73
+ "@modelcontextprotocol/sdk": "^1.29.0",
74
+ "@opencode-ai/sdk": "^1.17.4",
75
+ "zod": "^3.25.0",
76
+ "local-llm-cli-adapter": "github:ryan-mahoney/local-llm-cli-adapter#2ea1aa2d8e8dbe43eb845eb4730b08a02618f476"
72
77
  },
73
78
  "devDependencies": {
74
79
  "@eslint/js": "^9.37.0",
@@ -11,7 +11,10 @@ import {
11
11
  handleSubmit,
12
12
  parseTaskIndex,
13
13
  serializeTaskIndex,
14
+ buildUiCorsEnv,
15
+ program,
14
16
  } from "../index.ts";
17
+ import pkg from "../../../package.json";
15
18
 
16
19
  // ─── helpers ─────────────────────────────────────────────────────────────────
17
20
 
@@ -418,3 +421,30 @@ describe("serializeTaskIndex", () => {
418
421
  expect(serializeTaskIndex(map).endsWith("\n")).toBe(true);
419
422
  });
420
423
  });
424
+
425
+ // ─── buildUiCorsEnv ─────────────────────────────────────────────────────────
426
+
427
+ describe("buildUiCorsEnv", () => {
428
+ it("maps corsOrigins and corsAllowNullOrigin to env vars", () => {
429
+ const result = buildUiCorsEnv({ port: "4000", corsOrigins: "a,b", corsAllowNullOrigin: true });
430
+ expect(result).toEqual({ PO_CORS_ORIGINS: "a,b", PO_CORS_ALLOW_NULL_ORIGIN: "1" });
431
+ });
432
+
433
+ it("returns empty object when no CORS options provided", () => {
434
+ const result = buildUiCorsEnv({ port: "4000" });
435
+ expect(result).toEqual({});
436
+ });
437
+
438
+ it("maps only corsOrigins when corsAllowNullOrigin is false", () => {
439
+ const result = buildUiCorsEnv({ port: "4000", corsOrigins: "https://app.example" });
440
+ expect(result).toEqual({ PO_CORS_ORIGINS: "https://app.example" });
441
+ });
442
+ });
443
+
444
+ // ─── version ────────────────────────────────────────────────────────────────
445
+
446
+ describe("CLI version", () => {
447
+ it("uses the real CLI program version from package.json (AC-15)", () => {
448
+ expect(program.version()).toBe(pkg.version);
449
+ });
450
+ });
package/src/cli/index.ts CHANGED
@@ -9,6 +9,7 @@ import { updatePipelineJson } from "./update-pipeline-json.ts";
9
9
  import { analyzeTaskFile } from "./analyze-task.ts";
10
10
  import { submitJobWithValidation, PipelineOrchestrator } from "../api/index.ts";
11
11
  import type { Registry } from "./types.ts";
12
+ import pkg from "../../package.json";
12
13
 
13
14
  // ─── init ─────────────────────────────────────────────────────────────────────
14
15
 
@@ -28,9 +29,24 @@ export async function handleInit(root: string): Promise<void> {
28
29
 
29
30
  // ─── start ────────────────────────────────────────────────────────────────────
30
31
 
32
+ export interface StartOptions {
33
+ root?: string;
34
+ port: string;
35
+ corsOrigins?: string;
36
+ corsAllowNullOrigin?: boolean;
37
+ }
38
+
39
+ export function buildUiCorsEnv(opts: StartOptions): Record<string, string> {
40
+ const env: Record<string, string> = {};
41
+ if (opts.corsOrigins) env["PO_CORS_ORIGINS"] = opts.corsOrigins;
42
+ if (opts.corsAllowNullOrigin) env["PO_CORS_ALLOW_NULL_ORIGIN"] = "1";
43
+ return env;
44
+ }
45
+
31
46
  export async function handleStart(
32
47
  root: string | undefined,
33
- port: string
48
+ port: string,
49
+ opts?: StartOptions,
34
50
  ): Promise<void> {
35
51
  const rawRoot = root ?? process.env["PO_ROOT"];
36
52
  if (!rawRoot) {
@@ -60,6 +76,9 @@ export async function handleStart(
60
76
  );
61
77
  delete uiEnv["PO_UI_PORT"];
62
78
 
79
+ const corsEnv = opts ? buildUiCorsEnv(opts) : {};
80
+ Object.assign(uiEnv, corsEnv);
81
+
63
82
  const orchEnv: Record<string, string> = Object.fromEntries(
64
83
  Object.entries({ ...process.env, NODE_ENV: "production", PO_ROOT: absoluteRoot }).filter(
65
84
  (entry): entry is [string, string] => entry[1] !== undefined
@@ -408,12 +427,12 @@ async function handleRunJob(jobId: string): Promise<void> {
408
427
 
409
428
  // ─── Commander program ────────────────────────────────────────────────────────
410
429
 
411
- const program = new Command();
430
+ export const program = new Command();
412
431
 
413
432
  program
414
433
  .name("pipeline-orchestrator")
415
434
  .description("Prompt Orchestration Pipeline CLI")
416
- .version("0.17.5");
435
+ .version(pkg.version);
417
436
 
418
437
  program
419
438
  .command("init")
@@ -428,8 +447,10 @@ program
428
447
  .description("Start the UI server and orchestrator")
429
448
  .option("--root <path>", "Root directory")
430
449
  .option("--port <port>", "UI server port", "4000")
431
- .action(async (opts: { root?: string; port: string }) => {
432
- await handleStart(opts.root, opts.port);
450
+ .option("--cors-origins <origins>", "Comma-separated list of allowed CORS origins")
451
+ .option("--cors-allow-null-origin", "Allow requests with Origin: null")
452
+ .action(async (opts: StartOptions) => {
453
+ await handleStart(opts.root, opts.port, opts);
433
454
  });
434
455
 
435
456
  program
@@ -15,8 +15,8 @@ import {
15
15
  } from "../models";
16
16
  import type { ModelConfigEntry } from "../models";
17
17
 
18
- const MODEL_COUNT = 50;
19
- const PROVIDER_COUNT = 8;
18
+ const MODEL_COUNT = 51;
19
+ const PROVIDER_COUNT = 9;
20
20
 
21
21
  describe("ModelAlias", () => {
22
22
  it(`has exactly ${MODEL_COUNT} entries`, () => {
@@ -54,6 +54,20 @@ describe("MODEL_CONFIG", () => {
54
54
  }
55
55
  });
56
56
 
57
+ it("opencode:default has zero pricing", () => {
58
+ const config = getModelConfig("opencode:default");
59
+ expect(config).not.toBeNull();
60
+ expect(config!.tokenCostInPerMillion).toBe(0);
61
+ expect(config!.tokenCostOutPerMillion).toBe(0);
62
+ });
63
+
64
+ it("contains exactly one key with the opencode: prefix", () => {
65
+ const opencodeKeys = Object.keys(MODEL_CONFIG).filter((k) =>
66
+ k.startsWith("opencode:"),
67
+ );
68
+ expect(opencodeKeys).toEqual(["opencode:default"]);
69
+ });
70
+
57
71
  it("all entries have non-negative costs", () => {
58
72
  for (const [alias, entry] of Object.entries(MODEL_CONFIG)) {
59
73
  expect(entry.tokenCostInPerMillion).toBeGreaterThanOrEqual(0);
@@ -91,7 +105,7 @@ describe("VALID_MODEL_ALIASES", () => {
91
105
 
92
106
  describe("DEFAULT_MODEL_BY_PROVIDER", () => {
93
107
  it(`has entries for all ${PROVIDER_COUNT} providers`, () => {
94
- const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba"];
108
+ const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba", "opencode"];
95
109
  expect(Object.keys(DEFAULT_MODEL_BY_PROVIDER).length).toBe(PROVIDER_COUNT);
96
110
  for (const provider of providers) {
97
111
  expect(provider in DEFAULT_MODEL_BY_PROVIDER).toBe(true);
@@ -245,7 +259,7 @@ describe("FUNCTION_NAME_BY_ALIAS", () => {
245
259
 
246
260
  describe("PROVIDER_FUNCTIONS", () => {
247
261
  it(`has entries for all ${PROVIDER_COUNT} providers`, () => {
248
- const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba"];
262
+ const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba", "opencode"];
249
263
  for (const provider of providers) {
250
264
  expect(provider in PROVIDER_FUNCTIONS).toBe(true);
251
265
  }