@neuralnomads/codenomad 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ import path from "path"
2
+ import { tool } from "@opencode-ai/plugin/tool"
3
+
4
+ type BackgroundProcess = {
5
+ id: string
6
+ title: string
7
+ command: string
8
+ status: "running" | "stopped" | "error"
9
+ startedAt: string
10
+ stoppedAt?: string
11
+ exitCode?: number
12
+ outputSizeBytes?: number
13
+ }
14
+
15
+ type CodeNomadConfig = {
16
+ instanceId: string
17
+ baseUrl: string
18
+ }
19
+
20
+ type BackgroundProcessOptions = {
21
+ baseDir: string
22
+ }
23
+
24
+ type ParsedCommand = {
25
+ head: string
26
+ args: string[]
27
+ }
28
+
29
+ export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
30
+ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
31
+
32
+ const base = config.baseUrl.replace(/\/+$/, "")
33
+ const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
34
+ const headers = normalizeHeaders(init?.headers)
35
+ if (init?.body !== undefined) {
36
+ headers["Content-Type"] = "application/json"
37
+ }
38
+
39
+ const response = await fetch(url, {
40
+ ...init,
41
+ headers,
42
+ })
43
+
44
+ if (!response.ok) {
45
+ const message = await response.text()
46
+ throw new Error(message || `Request failed with ${response.status}`)
47
+ }
48
+
49
+ if (response.status === 204) {
50
+ return undefined as T
51
+ }
52
+
53
+ return (await response.json()) as T
54
+ }
55
+
56
+ return {
57
+ run_background_process: tool({
58
+ description:
59
+ "Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
60
+ args: {
61
+ title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
62
+ command: tool.schema.string().describe("Shell command to run in the workspace"),
63
+ },
64
+ async execute(args) {
65
+ assertCommandWithinBase(args.command, options.baseDir)
66
+ const process = await request<BackgroundProcess>("", {
67
+ method: "POST",
68
+ body: JSON.stringify({ title: args.title, command: args.command }),
69
+ })
70
+
71
+ return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
72
+ },
73
+ }),
74
+ list_background_processes: tool({
75
+ description: "List background processes running for this workspace.",
76
+ args: {},
77
+ async execute() {
78
+ const response = await request<{ processes: BackgroundProcess[] }>("")
79
+ if (response.processes.length === 0) {
80
+ return "No background processes running."
81
+ }
82
+
83
+ return response.processes
84
+ .map((process) => {
85
+ const status = process.status === "running" ? "running" : process.status
86
+ const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
87
+ const size =
88
+ typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
89
+ return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
90
+ })
91
+ .join("\n")
92
+ },
93
+ }),
94
+ read_background_process_output: tool({
95
+ description: "Read output from a background process. Use full, grep, head, or tail.",
96
+ args: {
97
+ id: tool.schema.string().describe("Background process ID"),
98
+ method: tool.schema
99
+ .enum(["full", "grep", "head", "tail"])
100
+ .default("full")
101
+ .describe("Method to read output"),
102
+ pattern: tool.schema.string().optional().describe("Pattern for grep method"),
103
+ lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
104
+ },
105
+ async execute(args) {
106
+ if (args.method === "grep" && !args.pattern) {
107
+ return "Pattern is required for grep method."
108
+ }
109
+
110
+ const params = new URLSearchParams({ method: args.method })
111
+ if (args.pattern) {
112
+ params.set("pattern", args.pattern)
113
+ }
114
+ if (args.lines) {
115
+ params.set("lines", String(args.lines))
116
+ }
117
+
118
+ const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
119
+ `/${args.id}/output?${params.toString()}`,
120
+ )
121
+
122
+ const header = response.truncated
123
+ ? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
124
+ : `Output (${Math.round(response.sizeBytes / 1024)}KB):`
125
+
126
+ return `${header}\n\n${response.content}`
127
+ },
128
+ }),
129
+ stop_background_process: tool({
130
+ description: "Stop a background process (SIGTERM) but keep its output and entry.",
131
+ args: {
132
+ id: tool.schema.string().describe("Background process ID"),
133
+ },
134
+ async execute(args) {
135
+ const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
136
+ return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
137
+ },
138
+ }),
139
+ terminate_background_process: tool({
140
+ description: "Terminate a background process and delete its output + entry.",
141
+ args: {
142
+ id: tool.schema.string().describe("Background process ID"),
143
+ },
144
+ async execute(args) {
145
+ await request<void>(`/${args.id}/terminate`, { method: "POST" })
146
+ return `Terminated background process ${args.id} and removed its output.`
147
+ },
148
+ }),
149
+ }
150
+ }
151
+
152
+ const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
153
+ const EXPANSION_CHARS = /[~*$?\[\]`$]/
154
+
155
+ function assertCommandWithinBase(command: string, baseDir: string) {
156
+ const normalizedBase = path.resolve(baseDir)
157
+ const commands = splitCommands(command)
158
+
159
+ for (const item of commands) {
160
+ if (!FILE_COMMANDS.has(item.head)) {
161
+ continue
162
+ }
163
+
164
+ for (const arg of item.args) {
165
+ if (!arg) continue
166
+ if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
167
+
168
+ const literalArg = unquote(arg)
169
+ if (EXPANSION_CHARS.test(literalArg)) {
170
+ throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
171
+ }
172
+
173
+ const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
174
+ if (!isWithinBase(normalizedBase, resolved)) {
175
+ throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ function splitCommands(command: string): ParsedCommand[] {
182
+ const tokens = tokenize(command)
183
+ const commands: ParsedCommand[] = []
184
+ let current: string[] = []
185
+
186
+ for (const token of tokens) {
187
+ if (isSeparator(token)) {
188
+ if (current.length > 0) {
189
+ commands.push({ head: current[0], args: current.slice(1) })
190
+ current = []
191
+ }
192
+ continue
193
+ }
194
+ current.push(token)
195
+ }
196
+
197
+ if (current.length > 0) {
198
+ commands.push({ head: current[0], args: current.slice(1) })
199
+ }
200
+
201
+ return commands
202
+ }
203
+
204
+ function tokenize(input: string): string[] {
205
+ const tokens: string[] = []
206
+ let current = ""
207
+ let quote: "'" | '"' | null = null
208
+ let escape = false
209
+
210
+ const flush = () => {
211
+ if (current.length > 0) {
212
+ tokens.push(current)
213
+ current = ""
214
+ }
215
+ }
216
+
217
+ for (let index = 0; index < input.length; index += 1) {
218
+ const char = input[index]
219
+
220
+ if (escape) {
221
+ current += char
222
+ escape = false
223
+ continue
224
+ }
225
+
226
+ if (char === "\\" && quote !== "'") {
227
+ escape = true
228
+ continue
229
+ }
230
+
231
+ if (quote) {
232
+ current += char
233
+ if (char === quote) {
234
+ quote = null
235
+ }
236
+ continue
237
+ }
238
+
239
+ if (char === "'" || char === '"') {
240
+ quote = char
241
+ current += char
242
+ continue
243
+ }
244
+
245
+ if (char === " " || char === "\n" || char === "\t") {
246
+ flush()
247
+ continue
248
+ }
249
+
250
+ if (char === "|" || char === "&" || char === ";") {
251
+ flush()
252
+ const next = input[index + 1]
253
+ if ((char === "|" || char === "&") && next === char) {
254
+ tokens.push(char + next)
255
+ index += 1
256
+ } else {
257
+ tokens.push(char)
258
+ }
259
+ continue
260
+ }
261
+
262
+ current += char
263
+ }
264
+
265
+ flush()
266
+ return tokens
267
+ }
268
+
269
+ function isSeparator(token: string) {
270
+ return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
271
+ }
272
+
273
+ function unquote(value: string) {
274
+ if (value.length >= 2) {
275
+ const first = value[0]
276
+ const last = value[value.length - 1]
277
+ if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
278
+ return value.slice(1, -1)
279
+ }
280
+ }
281
+ return value
282
+ }
283
+
284
+ function isWithinBase(baseDir: string, target: string) {
285
+ const relative = path.relative(baseDir, target)
286
+ if (!relative) return true
287
+ return !relative.startsWith("..") && !path.isAbsolute(relative)
288
+ }
289
+
290
+ function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
291
+ const output: Record<string, string> = {}
292
+ if (!headers) return output
293
+
294
+ if (headers instanceof Headers) {
295
+ headers.forEach((value, key) => {
296
+ output[key] = value
297
+ })
298
+ return output
299
+ }
300
+
301
+ if (Array.isArray(headers)) {
302
+ for (const [key, value] of headers) {
303
+ output[key] = value
304
+ }
305
+ return output
306
+ }
307
+
308
+ return { ...headers }
309
+ }
@@ -0,0 +1,165 @@
1
+ export type PluginEvent = {
2
+ type: string
3
+ properties?: Record<string, unknown>
4
+ }
5
+
6
+ export type CodeNomadConfig = {
7
+ instanceId: string
8
+ baseUrl: string
9
+ }
10
+
11
+ export function getCodeNomadConfig(): CodeNomadConfig {
12
+ return {
13
+ instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
14
+ baseUrl: requireEnv("CODENOMAD_BASE_URL"),
15
+ }
16
+ }
17
+
18
+ export function createCodeNomadClient(config: CodeNomadConfig) {
19
+ return {
20
+ postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
21
+ startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
22
+ }
23
+ }
24
+
25
+ function requireEnv(key: string): string {
26
+ const value = process.env[key]
27
+ if (!value || !value.trim()) {
28
+ throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
29
+ }
30
+ return value
31
+ }
32
+
33
+ function delay(ms: number) {
34
+ return new Promise<void>((resolve) => setTimeout(resolve, ms))
35
+ }
36
+
37
+ async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
38
+ const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
39
+ const response = await fetch(url, {
40
+ method: "POST",
41
+ headers: {
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify(event),
45
+ })
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
49
+ }
50
+ }
51
+
52
+ async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
53
+ const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
54
+
55
+ // Fail plugin startup if we cannot establish the initial connection.
56
+ const initialBody = await connectWithRetries(url, 3)
57
+
58
+ // After startup, keep reconnecting; throw after 3 consecutive failures.
59
+ void consumeWithReconnect(url, onEvent, initialBody)
60
+ }
61
+
62
+ async function connectWithRetries(url: string, maxAttempts: number) {
63
+ let lastError: unknown
64
+
65
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
66
+ try {
67
+ const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
68
+ if (!response.ok || !response.body) {
69
+ throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
70
+ }
71
+ return response.body
72
+ } catch (error) {
73
+ lastError = error
74
+ await delay(500 * attempt)
75
+ }
76
+ }
77
+
78
+ const reason = lastError instanceof Error ? lastError.message : String(lastError)
79
+ throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
80
+ }
81
+
82
+ async function consumeWithReconnect(
83
+ url: string,
84
+ onEvent: (event: PluginEvent) => void,
85
+ initialBody: ReadableStream<Uint8Array>,
86
+ ) {
87
+ let consecutiveFailures = 0
88
+ let body: ReadableStream<Uint8Array> | null = initialBody
89
+
90
+ while (true) {
91
+ try {
92
+ if (!body) {
93
+ body = await connectWithRetries(url, 3)
94
+ }
95
+
96
+ await consumeSseBody(body, onEvent)
97
+ body = null
98
+ consecutiveFailures = 0
99
+ } catch (error) {
100
+ body = null
101
+ consecutiveFailures += 1
102
+ if (consecutiveFailures >= 3) {
103
+ const reason = error instanceof Error ? error.message : String(error)
104
+ throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
105
+ }
106
+ await delay(500 * consecutiveFailures)
107
+ }
108
+ }
109
+ }
110
+
111
+ async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
112
+ const reader = body.getReader()
113
+ const decoder = new TextDecoder()
114
+ let buffer = ""
115
+
116
+ while (true) {
117
+ const { done, value } = await reader.read()
118
+ if (done || !value) {
119
+ break
120
+ }
121
+
122
+ buffer += decoder.decode(value, { stream: true })
123
+
124
+ let separatorIndex = buffer.indexOf("\n\n")
125
+ while (separatorIndex >= 0) {
126
+ const chunk = buffer.slice(0, separatorIndex)
127
+ buffer = buffer.slice(separatorIndex + 2)
128
+ separatorIndex = buffer.indexOf("\n\n")
129
+
130
+ const event = parseSseChunk(chunk)
131
+ if (event) {
132
+ onEvent(event)
133
+ }
134
+ }
135
+ }
136
+
137
+ throw new Error("SSE stream ended")
138
+ }
139
+
140
+ function parseSseChunk(chunk: string): PluginEvent | null {
141
+ const lines = chunk.split(/\r?\n/)
142
+ const dataLines: string[] = []
143
+
144
+ for (const line of lines) {
145
+ if (line.startsWith(":")) continue
146
+ if (line.startsWith("data:")) {
147
+ dataLines.push(line.slice(5).trimStart())
148
+ }
149
+ }
150
+
151
+ if (dataLines.length === 0) return null
152
+
153
+ const payload = dataLines.join("\n").trim()
154
+ if (!payload) return null
155
+
156
+ try {
157
+ const parsed = JSON.parse(payload)
158
+ if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
159
+ return null
160
+ }
161
+ return parsed as PluginEvent
162
+ } catch {
163
+ return null
164
+ }
165
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync } from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { createLogger } from "./logger";
5
+ const log = createLogger({ component: "opencode-config" });
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const devTemplateDir = path.resolve(__dirname, "../../opencode-config");
9
+ const resourcesPath = process.resourcesPath;
10
+ const prodTemplateDirs = [
11
+ resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
12
+ path.resolve(__dirname, "opencode-config"),
13
+ ].filter((dir) => Boolean(dir));
14
+ const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir);
15
+ const templateDir = isDevBuild
16
+ ? devTemplateDir
17
+ : prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0];
18
+ export function getOpencodeConfigDir() {
19
+ if (!existsSync(templateDir)) {
20
+ throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`);
21
+ }
22
+ if (isDevBuild) {
23
+ log.debug({ templateDir }, "Using Opencode config template directly (dev mode)");
24
+ }
25
+ return templateDir;
26
+ }
@@ -0,0 +1,40 @@
1
+ export class PluginChannelManager {
2
+ constructor(logger) {
3
+ this.logger = logger;
4
+ this.clients = new Set();
5
+ }
6
+ register(workspaceId, reply) {
7
+ const connection = { workspaceId, reply };
8
+ this.clients.add(connection);
9
+ this.logger.debug({ workspaceId }, "Plugin SSE client connected");
10
+ let closed = false;
11
+ const close = () => {
12
+ if (closed)
13
+ return;
14
+ closed = true;
15
+ this.clients.delete(connection);
16
+ this.logger.debug({ workspaceId }, "Plugin SSE client disconnected");
17
+ };
18
+ return { close };
19
+ }
20
+ send(workspaceId, event) {
21
+ for (const client of this.clients) {
22
+ if (client.workspaceId !== workspaceId)
23
+ continue;
24
+ this.write(client.reply, event);
25
+ }
26
+ }
27
+ broadcast(event) {
28
+ for (const client of this.clients) {
29
+ this.write(client.reply, event);
30
+ }
31
+ }
32
+ write(reply, event) {
33
+ try {
34
+ reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
35
+ }
36
+ catch (error) {
37
+ this.logger.warn({ err: error }, "Failed to write plugin SSE event");
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,17 @@
1
+ export function handlePluginEvent(workspaceId, event, deps) {
2
+ switch (event.type) {
3
+ case "codenomad.pong":
4
+ deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received");
5
+ return;
6
+ default:
7
+ deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event");
8
+ }
9
+ }
10
+ export function buildPingEvent() {
11
+ return {
12
+ type: "codenomad.ping",
13
+ properties: {
14
+ ts: Date.now(),
15
+ },
16
+ };
17
+ }
@@ -11,6 +11,9 @@ import { registerFilesystemRoutes } from "./routes/filesystem";
11
11
  import { registerMetaRoutes } from "./routes/meta";
12
12
  import { registerEventRoutes } from "./routes/events";
13
13
  import { registerStorageRoutes } from "./routes/storage";
14
+ import { registerPluginRoutes } from "./routes/plugin";
15
+ import { registerBackgroundProcessRoutes } from "./routes/background-processes";
16
+ import { BackgroundProcessManager } from "../background-processes/manager";
14
17
  const DEFAULT_HTTP_PORT = 9898;
15
18
  export function createHttpServer(deps) {
16
19
  const app = Fastify({ logger: false });
@@ -63,6 +66,11 @@ export function createHttpServer(deps) {
63
66
  headersTimeout: 0,
64
67
  },
65
68
  });
69
+ const backgroundProcessManager = new BackgroundProcessManager({
70
+ workspaceManager: deps.workspaceManager,
71
+ eventBus: deps.eventBus,
72
+ logger: deps.logger.child({ component: "background-processes" }),
73
+ });
66
74
  registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
67
75
  registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry });
68
76
  registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
@@ -73,6 +81,8 @@ export function createHttpServer(deps) {
73
81
  eventBus: deps.eventBus,
74
82
  workspaceManager: deps.workspaceManager,
75
83
  });
84
+ registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger });
85
+ registerBackgroundProcessRoutes(app, { backgroundProcessManager });
76
86
  registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger });
77
87
  if (deps.uiDevServerUrl) {
78
88
  setupDevProxy(app, deps.uiDevServerUrl);
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ const StartSchema = z.object({
3
+ title: z.string().trim().min(1),
4
+ command: z.string().trim().min(1),
5
+ });
6
+ const OutputQuerySchema = z.object({
7
+ method: z.enum(["full", "tail", "head", "grep"]).optional(),
8
+ mode: z.enum(["full", "tail", "head", "grep"]).optional(),
9
+ pattern: z.string().optional(),
10
+ lines: z.coerce.number().int().positive().max(2000).optional(),
11
+ maxBytes: z.coerce.number().int().positive().optional(),
12
+ });
13
+ export function registerBackgroundProcessRoutes(app, deps) {
14
+ app.get("/workspaces/:id/plugin/background-processes", async (request) => {
15
+ const processes = await deps.backgroundProcessManager.list(request.params.id);
16
+ return { processes };
17
+ });
18
+ app.post("/workspaces/:id/plugin/background-processes", async (request, reply) => {
19
+ const payload = StartSchema.parse(request.body ?? {});
20
+ const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command);
21
+ reply.code(201);
22
+ return process;
23
+ });
24
+ app.post("/workspaces/:id/plugin/background-processes/:processId/stop", async (request, reply) => {
25
+ const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId);
26
+ if (!process) {
27
+ reply.code(404);
28
+ return { error: "Process not found" };
29
+ }
30
+ return process;
31
+ });
32
+ app.post("/workspaces/:id/plugin/background-processes/:processId/terminate", async (request, reply) => {
33
+ await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId);
34
+ reply.code(204);
35
+ return undefined;
36
+ });
37
+ app.get("/workspaces/:id/plugin/background-processes/:processId/output", async (request, reply) => {
38
+ const query = OutputQuerySchema.parse(request.query ?? {});
39
+ const method = query.method ?? query.mode;
40
+ if (method === "grep" && !query.pattern) {
41
+ reply.code(400);
42
+ return { error: "Pattern is required for grep output" };
43
+ }
44
+ try {
45
+ return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
46
+ method,
47
+ pattern: query.pattern,
48
+ lines: query.lines,
49
+ maxBytes: query.maxBytes,
50
+ });
51
+ }
52
+ catch (error) {
53
+ reply.code(400);
54
+ return { error: error instanceof Error ? error.message : "Invalid output request" };
55
+ }
56
+ });
57
+ app.get("/workspaces/:id/plugin/background-processes/:processId/stream", async (request, reply) => {
58
+ await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply);
59
+ });
60
+ }