@love-moon/app-sdk 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # Copy to .env (or export inline) before running.
2
+
3
+ # Conductor backend (no trailing slash). Default is local dev.
4
+ CONDUCTOR_BASE_URL=http://localhost:6152
5
+
6
+ # API token from Conductor → Settings → API Tokens.
7
+ # Keep this secret. The CLI runs entirely server-side, so it stays out of
8
+ # any browser.
9
+ CONDUCTOR_TOKEN=ct_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
10
+
11
+ # Which daemon + workspace this CLI should run AI work against.
12
+ # The daemon must already be online and have reported the workspace path.
13
+ CONDUCTOR_DAEMON_HOST=duino-mbp
14
+ CONDUCTOR_WORKSPACE_PATH=/Users/me/work/acme
15
+
16
+ # Display name stamped on the project when it's first created.
17
+ CONDUCTOR_APP_NAME=App SDK CLI Example
@@ -0,0 +1,80 @@
1
+ # Minimal CLI example
2
+
3
+ The smallest possible app using `@love-moon/app-sdk`. About **35 lines of
4
+ business code** to:
5
+
6
+ 1. Connect to a Conductor backend with a user token.
7
+ 2. Find-or-create a project bound to a daemon + workspace.
8
+ 3. Create an AI task.
9
+ 4. Read one prompt from stdin, send it, stream the AI reply to stdout.
10
+
11
+ Pure Node — no React, no BFF, no web server. Use this to learn what the SDK
12
+ does end-to-end before integrating it into your own app.
13
+
14
+ > For a full-stack browser demo (BFF + React widget + SSE bridge), see
15
+ > the sibling [`../02_bff/`](../02_bff/).
16
+
17
+ ## Setup
18
+
19
+ ```bash
20
+ # 1. Build the SDK so the file: dependency resolves.
21
+ cd ../.. # → modules/app-sdk
22
+ npm install
23
+ npm run build
24
+
25
+ # 2. Install the example's local file: link.
26
+ cd examples/01_example
27
+ npm install
28
+
29
+ # 3. Configure.
30
+ cp .env.example .env
31
+ $EDITOR .env
32
+
33
+ # 4. Run. (Either source .env yourself or use a tool like dotenv-cli.)
34
+ export $(cat .env | grep -v '^#' | xargs)
35
+ npm start
36
+ ```
37
+
38
+ Prerequisites: a running Conductor backend (default `http://localhost:6152`)
39
+ and an online daemon registered for the configured workspace.
40
+
41
+ ## Sample session
42
+
43
+ ```text
44
+ → connecting to http://localhost:6152
45
+ → binding project "App SDK CLI Example" on duino-mbp:/Users/me/work/acme
46
+ project p_abc123 (reused)
47
+ You: list the three biggest files in this repo
48
+ → creating task
49
+ task t_xyz789
50
+
51
+ AI:
52
+ Sure — looking at the workspace…
53
+ 1. web/src/app/api/projects/route.ts (998 lines)
54
+ 2. web/src/lib/realtime/agent-gateway.ts (...)
55
+ 3. ...
56
+ ```
57
+
58
+ ## What it deliberately doesn't do
59
+
60
+ - **No multi-turn loop**: sends one prompt and exits. A real CLI would loop
61
+ on stdin with `client.tasks.sendMessage(taskId, content)` for each turn.
62
+ - **No interrupt UI**: nothing reads Ctrl+C to call `tasks.interrupt()`.
63
+ - **No error retry**: on transient `network_error` it just exits with code 1.
64
+
65
+ These are intentionally out of scope — the example is a teaching tool, not
66
+ a finished product. The SDK supports all of them; see `../../README.md`.
67
+
68
+ ## Code walkthrough
69
+
70
+ [`chat-cli.mjs`](./chat-cli.mjs) is annotated inline. The four SDK calls
71
+ are:
72
+
73
+ ```js
74
+ const client = await connect({ baseUrl, bearerToken });
75
+ const project = await client.projects.bind({ name, daemonHost, workspacePath });
76
+ const task = await client.tasks.create({ projectId: project.id, title, initialMessage });
77
+ for await (const delta of client.tasks.streamReply(task.id)) { /* … */ }
78
+ ```
79
+
80
+ Everything else (env parsing, stdin readline, terminal output) is plain Node.
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal CLI app: chat with your Conductor daemon from a terminal.
4
+ *
5
+ * What it shows: the four moves any third-party server-side integration
6
+ * needs to perform.
7
+ *
8
+ * 1. connect() — open an AppClient with a Conductor token.
9
+ * 2. client.projects.bind() — idempotent find-or-create of the
10
+ * project (daemon + workspace pair).
11
+ * 3. client.tasks.create() — start a new AI conversation in that
12
+ * project.
13
+ * 4. client.tasks.streamReply() — stream the AI reply token-preview by
14
+ * token-preview to stdout. (Send a
15
+ * message first via tasks.sendMessage,
16
+ * or use `initialMessage` on create.)
17
+ *
18
+ * Total business code: ~35 lines (the rest is config + UX).
19
+ */
20
+
21
+ import { createInterface } from 'node:readline/promises';
22
+ import { stdin, stdout, exit, env } from 'node:process';
23
+ import { connect } from '@love-moon/app-sdk/server';
24
+
25
+ function readRequired(key) {
26
+ const v = env[key];
27
+ if (!v) {
28
+ console.error(`Missing env var ${key}. See README for setup.`);
29
+ exit(1);
30
+ }
31
+ return v;
32
+ }
33
+
34
+ const config = {
35
+ baseUrl: env.CONDUCTOR_BASE_URL ?? 'http://localhost:6152',
36
+ token: readRequired('CONDUCTOR_TOKEN'),
37
+ daemonHost: readRequired('CONDUCTOR_DAEMON_HOST'),
38
+ workspacePath: readRequired('CONDUCTOR_WORKSPACE_PATH'),
39
+ appName: env.CONDUCTOR_APP_NAME ?? 'App SDK CLI Example',
40
+ };
41
+
42
+ async function main() {
43
+ console.log(`→ connecting to ${config.baseUrl}`);
44
+ const client = await connect({
45
+ baseUrl: config.baseUrl,
46
+ bearerToken: config.token,
47
+ onUnauthorized: () => console.error('! token rejected (401)'),
48
+ });
49
+
50
+ console.log(`→ binding project "${config.appName}" on ${config.daemonHost}:${config.workspacePath}`);
51
+ const project = await client.projects.bind({
52
+ name: config.appName,
53
+ daemonHost: config.daemonHost,
54
+ workspacePath: config.workspacePath,
55
+ });
56
+ console.log(` project ${project.id} (${project.createdByApp ? 'created' : 'reused'})`);
57
+
58
+ // Prompt the user for the initial message right away, so the task is
59
+ // created with content and the AI starts working immediately.
60
+ const rl = createInterface({ input: stdin, output: stdout });
61
+ const prompt = await rl.question('You: ');
62
+ rl.close();
63
+ if (!prompt.trim()) {
64
+ console.log('(empty input — nothing to do)');
65
+ await client.close();
66
+ return;
67
+ }
68
+
69
+ console.log(`→ creating task`);
70
+ const task = await client.tasks.create({
71
+ projectId: project.id,
72
+ title: prompt.slice(0, 60),
73
+ initialMessage: prompt,
74
+ });
75
+ console.log(` task ${task.id}`);
76
+
77
+ // Stream the AI reply. streamReply yields cumulative text previews; we
78
+ // print only the new tail each time so the terminal shows a smooth flow.
79
+ console.log('\nAI:');
80
+ let printedSoFar = '';
81
+ for await (const delta of client.tasks.streamReply(task.id)) {
82
+ if (delta.type === 'text') {
83
+ // The first delta is the full preview-so-far; later deltas are
84
+ // already diffed by the SDK. Most of the time the cumulative preview
85
+ // grows monotonically, but the backend may emit a reset (e.g. a tool
86
+ // call replaces the text). When that happens we print the whole new
87
+ // text rather than a corrupt slice.
88
+ stdout.write(delta.text);
89
+ if (delta.text && delta.text.startsWith(printedSoFar)) {
90
+ printedSoFar = delta.text;
91
+ } else {
92
+ // Reset path: the SDK already detected a non-continuation and is
93
+ // handing us the FULL new preview as `delta.text`, not an
94
+ // incremental tail. Tracking `printedSoFar + delta.text` here would
95
+ // double-count and corrupt the prefix used by the 'done' fill-in
96
+ // below.
97
+ printedSoFar = delta.text;
98
+ }
99
+ } else if (delta.type === 'done') {
100
+ // streamReply guarantees a single 'done' carrying the persisted message.
101
+ // If the rolling previews missed any trailing characters, fill them in
102
+ // (only when the persisted content is a continuation of what we printed).
103
+ const full = delta.message.content;
104
+ if (full.startsWith(printedSoFar) && printedSoFar.length < full.length) {
105
+ stdout.write(full.slice(printedSoFar.length));
106
+ }
107
+ stdout.write('\n');
108
+ break;
109
+ } else if (delta.type === 'error') {
110
+ stdout.write(`\n! AI failed: ${delta.error.message}\n`);
111
+ break;
112
+ }
113
+ // 'status' deltas are ignored in this minimal example.
114
+ }
115
+
116
+ await client.close();
117
+ }
118
+
119
+ main().catch((err) => {
120
+ // ConductorAppError has a stable `code` field for branching; for a CLI we
121
+ // just print message + code.
122
+ const code = err?.code ? ` [${err.code}]` : '';
123
+ console.error(`\n! ${err.message ?? err}${code}`);
124
+ exit(1);
125
+ });
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "conductor-app-sdk-cli-example",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "conductor-app-sdk-cli-example",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "@love-moon/app-sdk": "file:../.."
12
+ }
13
+ },
14
+ "../..": {
15
+ "name": "@love-moon/app-sdk",
16
+ "version": "0.1.0",
17
+ "dependencies": {
18
+ "ws": "^8.18.0",
19
+ "zod": "^3.24.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.10.2",
23
+ "@types/react": "^19.0.0",
24
+ "@types/react-dom": "^19.0.0",
25
+ "@types/ws": "^8.5.12",
26
+ "@vitejs/plugin-react": "^4.3.4",
27
+ "jsdom": "^25.0.1",
28
+ "react": "^19.0.0",
29
+ "react-dom": "^19.0.0",
30
+ "tsup": "^8.3.5",
31
+ "typescript": "^5.6.3",
32
+ "vitest": "^2.1.4"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=18.0.0",
36
+ "react-dom": ">=18.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "react": {
40
+ "optional": true
41
+ },
42
+ "react-dom": {
43
+ "optional": true
44
+ }
45
+ }
46
+ },
47
+ "node_modules/@love-moon/app-sdk": {
48
+ "resolved": "../..",
49
+ "link": true
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "conductor-app-sdk-cli-example",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "Minimal CLI app that talks to a user's Conductor daemon via @love-moon/app-sdk.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node chat-cli.mjs"
9
+ },
10
+ "dependencies": {
11
+ "@love-moon/app-sdk": "file:../.."
12
+ }
13
+ }
@@ -0,0 +1,16 @@
1
+ # Copy to .env.local and fill in.
2
+
3
+ # Conductor backend base URL (no trailing slash).
4
+ CONDUCTOR_BASE_URL=http://localhost:6152
5
+
6
+ # API token issued from Conductor Settings → API Tokens.
7
+ # IMPORTANT: keep this server-side only. Never embed in client code.
8
+ CONDUCTOR_TOKEN=ct_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
9
+
10
+ # The daemon + workspace this app should run AI tasks against.
11
+ # Must already be online + registered with Conductor.
12
+ CONDUCTOR_DAEMON_HOST=duino-mbp
13
+ CONDUCTOR_WORKSPACE_PATH=/Users/me/work/acme
14
+
15
+ # The display name shown to the user inside Conductor for this app.
16
+ CONDUCTOR_APP_NAME=App SDK Demo
@@ -0,0 +1,63 @@
1
+ # Conductor App SDK — end-to-end demo
2
+
3
+ A tiny Next.js app that uses `@love-moon/app-sdk` to embed a chat with a
4
+ user's Conductor AI tool. **Total business code: ~120 lines.**
5
+
6
+ ```
7
+ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
8
+ │ browser: │ │ /api/conductor │ │ Conductor backend │
9
+ │ <ChatView /> │ ──── │ Next.js BFF │ ──── │ + /ws/app + daemon │
10
+ │ + REST adapter │ SSE │ app-sdk/server │ │ │
11
+ └─────────────────┘ └─────────────────┘ └──────────────────────┘
12
+ ```
13
+
14
+ ## Run it
15
+
16
+ ```bash
17
+ # 1. Build the SDK (the example uses file: link to ../..).
18
+ cd ../.. && npm run build && cd -
19
+
20
+ # 2. Install + start.
21
+ npm install
22
+ cp .env.example .env.local
23
+ $EDITOR .env.local # fill in CONDUCTOR_TOKEN + DAEMON_HOST + WORKSPACE_PATH
24
+ npm run dev # → http://localhost:3001
25
+ ```
26
+
27
+ You need a running Conductor backend (typically `http://localhost:6152` via
28
+ `cd web && pnpm dev`) and an online daemon (typically `make debug-cli`).
29
+
30
+ ## What it shows
31
+
32
+ | File | Concern |
33
+ | --- | --- |
34
+ | `lib/conductor.ts` | Server-side singleton `AppClient`; `projects.bind()` to find-or-create the demo project (idempotent). |
35
+ | `app/api/conductor/bind/route.ts` | Page bootstrap: bind project + create a fresh task; return ids. |
36
+ | `app/api/conductor/[...path]/route.ts` | Catch-all BFF that forwards 4 routes to Conductor:<br>• `GET /tasks/:id/messages` → `tasks.history()`<br>• `POST /tasks/:id/messages` → `tasks.sendMessage()`<br>• `POST /tasks/:id/interrupt` → `tasks.interrupt()`<br>• `GET /tasks/:id/events` → SSE bridge over `tasks.subscribe()` |
37
+ | `app/page.tsx` | React page: bootstrap, mount `<ChatView />`. |
38
+
39
+ The SSE bridge (~30 lines in the catch-all) is the most interesting piece —
40
+ that's how the SDK's server-side AsyncIterable becomes a client-side
41
+ EventSource without needing a custom Next.js server.
42
+
43
+ ## What it deliberately skips
44
+
45
+ - **End-user authentication**: the BFF trusts the local browser session. A
46
+ real app would gate every route behind its own auth (cookie / JWT).
47
+ - **Persistence**: every page load creates a new task. A real app would
48
+ persist `(userId, taskId)` somewhere and resume.
49
+ - **CSRF protection**: standard Next.js patterns apply, not shown here.
50
+ - **Rate limiting**: trivial to add at the BFF (per user / per IP).
51
+
52
+ These are intentionally out of scope — they're host application concerns,
53
+ not SDK concerns.
54
+
55
+ ## Common errors
56
+
57
+ | Symptom | Cause |
58
+ | --- | --- |
59
+ | `Missing env var CONDUCTOR_TOKEN` on bind | `.env.local` not configured. |
60
+ | Bind returns `daemon_offline` | Daemon at `CONDUCTOR_DAEMON_HOST` isn't running. Start the daemon (`make debug-cli` for dev). |
61
+ | Bind returns `binding_validation_failed` | Daemon doesn't recognize the `CONDUCTOR_WORKSPACE_PATH`. Use a path the daemon has already reported. |
62
+ | SSE silently disconnects after ~30s | Reverse proxy buffering. The response sets `X-Accel-Buffering: no`; check your proxy config. |
63
+ | `401 unauthorized` | Token revoked or invalid. Mint a new one in Conductor Settings → API Tokens. |
@@ -0,0 +1,277 @@
1
+ /**
2
+ * BFF pass-through for the widget's default REST adapter.
3
+ *
4
+ * Routes (relative to this catch-all):
5
+ *
6
+ * GET /tasks/:taskId/messages?pagination=1&limit&before_id
7
+ * → forwarded to client.tasks.history()
8
+ * POST /tasks/:taskId/messages
9
+ * → forwarded to client.tasks.sendMessage()
10
+ * POST /tasks/:taskId/interrupt
11
+ * → forwarded to client.tasks.interrupt()
12
+ * GET /tasks/:taskId/events
13
+ * → SSE stream from client.tasks.subscribe()
14
+ *
15
+ * In a real BFF you'd:
16
+ * - Authenticate the requesting *user* (cookie / JWT) before each call.
17
+ * - Look up which Conductor task they're allowed to drive.
18
+ * - Probably rate-limit per user.
19
+ * - Possibly translate / strip metadata before forwarding to Conductor.
20
+ *
21
+ * The demo skips all of that — it trusts the local browser session.
22
+ */
23
+ import { NextRequest, NextResponse } from 'next/server';
24
+ import { getClient } from '@/lib/conductor';
25
+ import { isConductorAppError, ConductorAppError } from '@love-moon/app-sdk';
26
+
27
+ export const runtime = 'nodejs';
28
+ // SSE streams are long-lived; tell Next not to time them out.
29
+ export const dynamic = 'force-dynamic';
30
+
31
+ interface RouteContext {
32
+ params: Promise<{ path: string[] }>;
33
+ }
34
+
35
+ export async function GET(req: NextRequest, ctx: RouteContext) {
36
+ const segments = (await ctx.params).path ?? [];
37
+ // Expect /tasks/:taskId/<messages|events>
38
+ if (segments[0] !== 'tasks' || !segments[1]) return notFound();
39
+ const taskId = decodeURIComponent(segments[1]);
40
+ const op = segments[2];
41
+
42
+ try {
43
+ const client = await getClient();
44
+ if (op === 'messages') {
45
+ const url = new URL(req.url);
46
+ const beforeId = url.searchParams.get('before_id') ?? undefined;
47
+ const limitParam = url.searchParams.get('limit');
48
+ const limit = limitParam ? Number.parseInt(limitParam, 10) : undefined;
49
+ const page = await client.tasks.history(taskId, { beforeId, limit });
50
+ // Translate to the wire shape the widget's REST adapter expects.
51
+ return NextResponse.json({
52
+ messages: page.messages,
53
+ pagination: {
54
+ has_more_before: page.hasMoreBefore,
55
+ oldest_message_id: page.oldestMessageId,
56
+ },
57
+ });
58
+ }
59
+
60
+ if (op === 'events') {
61
+ return startEventStream(req, taskId);
62
+ }
63
+
64
+ return notFound();
65
+ } catch (err) {
66
+ return errorResponse(err);
67
+ }
68
+ }
69
+
70
+ export async function POST(req: NextRequest, ctx: RouteContext) {
71
+ const segments = (await ctx.params).path ?? [];
72
+ if (segments[0] !== 'tasks' || !segments[1]) return notFound();
73
+ const taskId = decodeURIComponent(segments[1]);
74
+ const op = segments[2];
75
+
76
+ try {
77
+ const client = await getClient();
78
+ const body = await req.json().catch(() => ({}));
79
+
80
+ if (op === 'messages') {
81
+ const content = String(body?.content ?? '');
82
+ if (!content) {
83
+ return NextResponse.json({ error: 'content required' }, { status: 400 });
84
+ }
85
+ // Threat model: the BFF is a trust boundary between the browser and
86
+ // Conductor. The browser cannot be trusted to:
87
+ // - Author messages as `system` / `assistant` (would let an attacker
88
+ // forge AI replies in the chat log).
89
+ // - Stamp `audit.actor='app'` (would let an attacker disguise a
90
+ // browser-originated message as a server-side app message and
91
+ // defeat `streamReply`'s SDK-echo filter).
92
+ // We therefore hard-code role='user' and strip metadata.audit before
93
+ // forwarding. The SDK then stamps its own audit fields server-side.
94
+ const incomingMetadata =
95
+ body?.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
96
+ ? { ...(body.metadata as Record<string, unknown>) }
97
+ : undefined;
98
+ if (incomingMetadata) delete incomingMetadata.audit;
99
+ const msg = await client.tasks.sendMessage(taskId, {
100
+ content,
101
+ clientRequestId: typeof body?.clientRequestId === 'string' ? body.clientRequestId : undefined,
102
+ role: 'user',
103
+ ...(incomingMetadata ? { metadata: incomingMetadata } : {}),
104
+ });
105
+ return NextResponse.json(msg);
106
+ }
107
+
108
+ if (op === 'interrupt') {
109
+ const targetReplyTo = String(body?.target_reply_to ?? body?.targetReplyTo ?? '');
110
+ if (!targetReplyTo) {
111
+ return NextResponse.json({ error: 'target_reply_to required' }, { status: 400 });
112
+ }
113
+ await client.tasks.interrupt(taskId, { targetReplyTo });
114
+ return NextResponse.json({ ok: true });
115
+ }
116
+
117
+ return notFound();
118
+ } catch (err) {
119
+ return errorResponse(err);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Bridge the SDK's `subscribe(taskId)` AsyncIterable to a Server-Sent Events
125
+ * response. The widget connects via `new EventSource(...)` and renders each
126
+ * `data: <JSON>` line as a ChatEvent.
127
+ *
128
+ * Lifecycle:
129
+ * - Client navigates away → req.signal aborts → we break the loop, the
130
+ * AsyncIterator's return() runs, and the underlying WS subscription is
131
+ * released.
132
+ */
133
+ async function startEventStream(req: NextRequest, taskId: string): Promise<Response> {
134
+ const client = await getClient();
135
+ const encoder = new TextEncoder();
136
+ const abortController = new AbortController();
137
+ // Name the listener so we can remove it in cleanup. Otherwise long-lived
138
+ // edge runtimes / proxies that reuse the same request signal would leak
139
+ // listeners across SSE streams.
140
+ const onRequestAbort = (): void => abortController.abort();
141
+ let removeReqAbortListener: (() => void) | null = null;
142
+ if (req.signal.aborted) {
143
+ abortController.abort();
144
+ } else {
145
+ req.signal.addEventListener('abort', onRequestAbort, { once: true });
146
+ removeReqAbortListener = () => {
147
+ req.signal.removeEventListener('abort', onRequestAbort);
148
+ };
149
+ }
150
+
151
+ // Keep-alive timer: emit an SSE comment every 15s to keep idle
152
+ // connections alive past proxy timeouts (nginx default 60s,
153
+ // some CDNs lower).
154
+ let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
155
+ let closed = false;
156
+
157
+ const stream = new ReadableStream({
158
+ async start(controller) {
159
+ // Safe wrapper: enqueue can throw `TypeError` once the controller
160
+ // is closed (e.g. after `cancel()` fires). Returning false signals
161
+ // the producer loop to bail out.
162
+ const safeEnqueue = (chunk: Uint8Array): boolean => {
163
+ if (closed) return false;
164
+ try {
165
+ controller.enqueue(chunk);
166
+ return true;
167
+ } catch {
168
+ closed = true;
169
+ return false;
170
+ }
171
+ };
172
+
173
+ // SSE preamble: a comment line tells some proxies "keep this alive".
174
+ if (!safeEnqueue(encoder.encode(':ok\n\n'))) return;
175
+
176
+ keepAliveTimer = setInterval(() => {
177
+ // Backpressure check: if the client isn't draining the stream fast
178
+ // enough (negative desiredSize means the internal queue is over the
179
+ // high-water mark), skip this keep-alive tick rather than piling
180
+ // more chunks into the buffer. The real events that follow are
181
+ // small; missing a keep-alive comment is harmless.
182
+ if (typeof controller.desiredSize === 'number' && controller.desiredSize < 0) {
183
+ return;
184
+ }
185
+ if (!safeEnqueue(encoder.encode(': keepalive\n\n'))) {
186
+ if (keepAliveTimer) {
187
+ clearInterval(keepAliveTimer);
188
+ keepAliveTimer = null;
189
+ }
190
+ }
191
+ }, 15_000);
192
+
193
+ try {
194
+ for await (const event of client.tasks.subscribe(taskId, {
195
+ signal: abortController.signal,
196
+ })) {
197
+ if (closed || req.signal.aborted) break;
198
+ const ok = safeEnqueue(
199
+ encoder.encode(`data: ${JSON.stringify(event)}\n\n`),
200
+ );
201
+ if (!ok) break;
202
+ }
203
+ } catch (err) {
204
+ // Surface terminal errors as a synthetic event then close. Preserve
205
+ // the original ConductorAppError `code` (e.g. `task_not_running`,
206
+ // `daemon_offline`) rather than hard-coding `subscribe_failed` — the
207
+ // browser-side widget switches its UI hint based on the code, so
208
+ // collapsing every terminal error to a single bucket would lose
209
+ // useful disambiguation. Fall back to `subscribe_failed` only when
210
+ // the thrown value isn't an SDK error.
211
+ const code =
212
+ err instanceof ConductorAppError ? err.code : 'subscribe_failed';
213
+ const payload = {
214
+ type: 'task_failed',
215
+ taskId,
216
+ error: {
217
+ code,
218
+ message: (err as Error)?.message ?? 'subscribe stream ended',
219
+ },
220
+ };
221
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
222
+ } finally {
223
+ cleanup();
224
+ if (!closed) {
225
+ try {
226
+ controller.close();
227
+ } catch {
228
+ /* already closed */
229
+ }
230
+ }
231
+ }
232
+ },
233
+ cancel() {
234
+ abortController.abort();
235
+ cleanup();
236
+ },
237
+ });
238
+
239
+ function cleanup() {
240
+ closed = true;
241
+ if (keepAliveTimer) {
242
+ clearInterval(keepAliveTimer);
243
+ keepAliveTimer = null;
244
+ }
245
+ if (removeReqAbortListener) {
246
+ removeReqAbortListener();
247
+ removeReqAbortListener = null;
248
+ }
249
+ }
250
+
251
+ return new Response(stream, {
252
+ headers: {
253
+ 'Content-Type': 'text/event-stream; charset=utf-8',
254
+ 'Cache-Control': 'no-cache, no-transform',
255
+ Connection: 'keep-alive',
256
+ // Prevent buffering proxies (nginx) from holding events back.
257
+ 'X-Accel-Buffering': 'no',
258
+ },
259
+ });
260
+ }
261
+
262
+ function notFound() {
263
+ return NextResponse.json({ error: 'Not found' }, { status: 404 });
264
+ }
265
+
266
+ function errorResponse(err: unknown) {
267
+ if (isConductorAppError(err)) {
268
+ return NextResponse.json(
269
+ { error: err.message, code: err.code },
270
+ { status: err.status ?? 500 },
271
+ );
272
+ }
273
+ return NextResponse.json(
274
+ { error: (err as Error)?.message ?? 'Internal error' },
275
+ { status: 500 },
276
+ );
277
+ }