@frontera-sdk/cli 1.46.6 → 1.46.7

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,655 @@
1
+ import {
2
+ type ForgeChatRequest,
3
+ type ForgeCreateTerminalInput,
4
+ type ForgeCreateThreadInput,
5
+ type ForgeHostId,
6
+ type ForgeTerminalClientMessage,
7
+ type ForgeWellKnown,
8
+ } from '@frontera-sdk/forge-contracts'
9
+ import { createUIMessageStream, createUIMessageStreamResponse, type UIMessageChunk } from 'ai'
10
+
11
+ import { KIT } from '../kit'
12
+ import type { AdapterFactory } from './adapter'
13
+ import { ClaudeAdapter } from './claude-adapter'
14
+ import { CodexAdapter } from './codex-adapter'
15
+ import {
16
+ HOSTS,
17
+ type Exec,
18
+ defaultEffortFor,
19
+ firstInstalledHostId,
20
+ isHostId,
21
+ isHostMode,
22
+ listHosts,
23
+ modeIdsFor,
24
+ } from './hosts'
25
+ import { ForgeHttpError, badRequest, errorBody, json, notFound, okBody, readJson } from './http'
26
+ import type { Logger } from './log'
27
+ import { forgePath } from './env'
28
+ import { FORGE_HEADERS, FORGE_WELL_KNOWN_PATH } from './protocol'
29
+ import { commandsFor } from './claude-commands'
30
+ import { skillsFor } from './claude-skills'
31
+ import { normalizeTurnText } from './turn-text'
32
+ import { ensureWorkspaceProject, inspectRoot, listFiles, readChanges } from './projects'
33
+ import { RunManager } from './runs'
34
+ import { sessionView } from './sessions'
35
+ import { ForgeStore, type StoredThread, type ThreadPatch } from './store'
36
+ import { TerminalManager, type TerminalClient } from './terminals'
37
+
38
+ /**
39
+ * The daemon's HTTP surface: the Forge routes and the platform-compatible
40
+ * chat/session routes, documented in packages/forge-contracts/README.md.
41
+ *
42
+ * Loopback only, one bearer token, one allowed browser origin. There is no
43
+ * multi-tenancy to model — the process belongs to the developer who started
44
+ * it — so the checks are a token compare and an origin compare, nothing more.
45
+ */
46
+
47
+ export interface ForgeServerOptions {
48
+ port: number
49
+ token: string
50
+ /** Browser origin allowed by CORS. Null: no browser may call (curl still can). */
51
+ origin: string | null
52
+ /** Platform API origin, for the MCP injection. '' when unknown. */
53
+ apiUrl: string
54
+ version: string
55
+ /** Path of the sqlite file, or ':memory:'. */
56
+ storePath: string
57
+ log: Logger
58
+ adapters?: AdapterFactory
59
+ /**
60
+ * How host binaries are probed. Defaults to spawning them. A test supplies
61
+ * its own so which harnesses happen to be installed on the machine running
62
+ * it cannot change the answer.
63
+ */
64
+ hostExec?: Exec
65
+ }
66
+
67
+ export interface ForgeServer {
68
+ port: number
69
+ stop(): Promise<void>
70
+ }
71
+
72
+ const ALLOW_HEADERS = 'authorization, content-type, x-workspace-id, x-org-id'
73
+ const ALLOW_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
74
+ const EXPOSE_HEADERS = [FORGE_HEADERS.conversationId, FORGE_HEADERS.runId, FORGE_HEADERS.agentRuntime].join(', ')
75
+
76
+ /** Under Bun's 255s ceiling and well over the 15s keepalive. */
77
+ const IDLE_TIMEOUT_S = 255
78
+ const KEEPALIVE_MS = 15_000
79
+ const PING = new TextEncoder().encode(': ping\n\n')
80
+
81
+ type Params = Record<string, string>
82
+ type Handler = (req: Request, params: Params, url: URL) => Promise<Response> | Response
83
+
84
+ interface Route {
85
+ method: string
86
+ segments: string[]
87
+ handler: Handler
88
+ }
89
+
90
+ interface WsData {
91
+ terminalId: string
92
+ client: TerminalClient | null
93
+ }
94
+
95
+ function segmentsOf(path: string): string[] {
96
+ return path.split('/').filter((s) => s !== '')
97
+ }
98
+
99
+ function matchRoute(route: Route, method: string, segments: string[]): Params | null {
100
+ if (route.method !== method || route.segments.length !== segments.length) return null
101
+ const params: Params = {}
102
+ for (let i = 0; i < segments.length; i += 1) {
103
+ const want = route.segments[i]!
104
+ const got = segments[i]!
105
+ if (want.startsWith(':')) params[want.slice(1)] = decodeURIComponent(got)
106
+ else if (want !== got) return null
107
+ }
108
+ return params
109
+ }
110
+
111
+ function withHeaders(res: Response, headers: Record<string, string>): Response {
112
+ const merged = new Headers(res.headers)
113
+ for (const [k, v] of Object.entries(headers)) merged.set(k, v)
114
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: merged })
115
+ }
116
+
117
+ /**
118
+ * Interleave SSE comment pings so a silent tool run never trips an idle
119
+ * timeout — the same shape as the platform's `withSseKeepalive`. Pump-based,
120
+ * because pings must flow on wall time rather than on downstream demand.
121
+ */
122
+ function withKeepalive(body: ReadableStream<Uint8Array>, intervalMs = KEEPALIVE_MS): ReadableStream<Uint8Array> {
123
+ const reader = body.getReader()
124
+ let timer: ReturnType<typeof setInterval> | null = null
125
+ const stop = () => {
126
+ if (timer) clearInterval(timer)
127
+ timer = null
128
+ }
129
+ return new ReadableStream<Uint8Array>({
130
+ start(controller) {
131
+ timer = setInterval(() => {
132
+ try {
133
+ controller.enqueue(PING)
134
+ } catch {
135
+ stop()
136
+ }
137
+ }, intervalMs)
138
+ void (async () => {
139
+ try {
140
+ for (;;) {
141
+ const { done, value } = await reader.read()
142
+ if (done) break
143
+ controller.enqueue(value)
144
+ }
145
+ stop()
146
+ controller.close()
147
+ } catch (err) {
148
+ stop()
149
+ try {
150
+ controller.error(err)
151
+ } catch {
152
+ // Already closed.
153
+ }
154
+ }
155
+ })()
156
+ },
157
+ cancel(reason) {
158
+ stop()
159
+ return reader.cancel(reason)
160
+ },
161
+ })
162
+ }
163
+
164
+ /**
165
+ * A mode is only meaningful against the host that declared it — Claude Code's
166
+ * `acceptEdits` means nothing to Codex — so validation always carries a host.
167
+ */
168
+ function requireHostMode(host: ForgeHostId, value: unknown): string {
169
+ if (!isHostMode(host, value)) {
170
+ throw badRequest(`mode must be one of: ${modeIdsFor(host).join(', ')}`)
171
+ }
172
+ return value
173
+ }
174
+
175
+ function optionalString(value: unknown, field: string): string | null | undefined {
176
+ if (value === undefined) return undefined
177
+ if (value === null) return null
178
+ if (typeof value !== 'string') throw badRequest(`${field} must be a string`)
179
+ return value
180
+ }
181
+
182
+ export function defaultAdapters(log: Logger): AdapterFactory {
183
+ return (host) => (host === 'claude' ? new ClaudeAdapter({ log }) : new CodexAdapter())
184
+ }
185
+
186
+ export function startForgeServer(opts: ForgeServerOptions): ForgeServer {
187
+ const { log } = opts
188
+ const store = new ForgeStore(opts.storePath)
189
+ const reaped = store.reapOrphanRuns()
190
+ if (reaped > 0) log.warn(`${reaped} run(s) from a previous daemon marked interrupted`)
191
+ const runs = new RunManager(store, opts.adapters ?? defaultAdapters(log), log, opts.apiUrl)
192
+ const terminals = new TerminalManager(log)
193
+
194
+ const corsHeaders = (): Record<string, string> =>
195
+ opts.origin
196
+ ? {
197
+ 'Access-Control-Allow-Origin': opts.origin,
198
+ 'Access-Control-Allow-Headers': ALLOW_HEADERS,
199
+ 'Access-Control-Allow-Methods': ALLOW_METHODS,
200
+ 'Access-Control-Expose-Headers': EXPOSE_HEADERS,
201
+ Vary: 'Origin',
202
+ }
203
+ : {}
204
+
205
+ const ok = <T>(data: T, headers: Record<string, string> = {}): Response => json(okBody(data), { headers })
206
+ const fail = (status: number, message: string, code: string): Response => json(errorBody(message, code), { status })
207
+
208
+ const requireProject = (id: string) => {
209
+ const project = store.getProject(id)
210
+ if (!project) throw notFound('project')
211
+ return project
212
+ }
213
+ const requireThread = (id: string) => {
214
+ const thread = store.getThread(id)
215
+ if (!thread) throw notFound('thread')
216
+ return thread
217
+ }
218
+
219
+ const routes: Route[] = []
220
+ const route = (method: string, path: string, handler: Handler) => {
221
+ routes.push({ method, segments: segmentsOf(path), handler })
222
+ }
223
+
224
+ // -- readiness ------------------------------------------------------------
225
+
226
+ route('GET', FORGE_WELL_KNOWN_PATH, () => {
227
+ const body: ForgeWellKnown = {
228
+ service: 'frontera-forge',
229
+ version: opts.version,
230
+ kitVersion: KIT.kitVersion ?? null,
231
+ pid: process.pid,
232
+ }
233
+ return ok(body)
234
+ })
235
+
236
+ route('GET', '/v1/forge/hosts', () => ok(listHosts(opts.hostExec)))
237
+
238
+ // -- projects -------------------------------------------------------------
239
+
240
+ route('GET', '/v1/forge/projects', () => ok(store.listProjects()))
241
+
242
+ route('POST', '/v1/forge/projects', async (req) => {
243
+ const body = await readJson<{ root?: unknown }>(req)
244
+ const detected = inspectRoot(typeof body.root === 'string' ? body.root : '')
245
+ const existing = store.projectByRoot(detected.root)
246
+ if (existing) return ok(store.refreshProject(existing.id, detected))
247
+ const project = store.insertProject(detected)
248
+ log.info(`project ${project.id} opened at ${project.root}`)
249
+ return ok(project)
250
+ })
251
+
252
+ // The managed folder for the workspace the developer is in — no picker.
253
+ route('POST', '/v1/forge/projects/workspace', async (req) => {
254
+ const body = await readJson<{ workspaceId?: unknown; workspaceSlug?: unknown; workspaceName?: unknown }>(req)
255
+ if (typeof body.workspaceId !== 'string' || !body.workspaceId) throw badRequest('workspaceId is required')
256
+ if (typeof body.workspaceSlug !== 'string' || !body.workspaceSlug) throw badRequest('workspaceSlug is required')
257
+ const detected = ensureWorkspaceProject(
258
+ {
259
+ workspaceId: body.workspaceId,
260
+ workspaceSlug: body.workspaceSlug,
261
+ ...(typeof body.workspaceName === 'string' ? { workspaceName: body.workspaceName } : {}),
262
+ },
263
+ opts.apiUrl || null,
264
+ (message) => log.warn(message),
265
+ )
266
+ const existing = store.projectByWorkspace(body.workspaceId) ?? store.projectByRoot(detected.root)
267
+ if (existing) return ok(store.refreshProject(existing.id, detected))
268
+ const project = store.insertProject(detected)
269
+ log.info(`workspace project ${project.id} at ${project.root}`)
270
+ return ok(project)
271
+ })
272
+
273
+ route('DELETE', '/v1/forge/projects/:id', async (_req, params) => {
274
+ const id = params.id!
275
+ requireProject(id)
276
+ for (const thread of store.listThreads(id)) {
277
+ const active = runs.activeRunFor(thread.id)
278
+ if (active) await runs.cancel(active)
279
+ }
280
+ store.deleteProject(id)
281
+ return ok({ id })
282
+ })
283
+
284
+ route('GET', '/v1/forge/projects/:id/threads', (_req, params) =>
285
+ ok(store.listThreads(params.id!).map(({ archived: _archived, ...thread }) => thread)))
286
+
287
+ route('GET', '/v1/forge/projects/:id/files', (_req, params, url) => {
288
+ const project = requireProject(params.id!)
289
+ return ok(listFiles(project.root, url.searchParams.get('path') ?? ''))
290
+ })
291
+
292
+ // What the composer's `/` menu lists: the skills the host would load for a
293
+ // session in this folder, read from the same directories the CLI reads.
294
+ route('GET', '/v1/forge/projects/:id/skills', (_req, params) => {
295
+ const project = requireProject(params.id!)
296
+ return ok(skillsFor(project.root))
297
+ })
298
+
299
+ // The host's own slash commands, from its initialization handshake.
300
+ route('GET', '/v1/forge/projects/:id/commands', (_req, params) => {
301
+ const project = requireProject(params.id!)
302
+ const which = (bin: string) => Bun.which(bin, { PATH: forgePath() })
303
+ return ok(commandsFor(project.root, which('claude'), log))
304
+ })
305
+
306
+ route('GET', '/v1/forge/projects/:id/changes', (_req, params) => {
307
+ const project = requireProject(params.id!)
308
+ return ok(readChanges(project.root))
309
+ })
310
+
311
+ // -- threads --------------------------------------------------------------
312
+
313
+ route('POST', '/v1/forge/threads', async (req) => {
314
+ const body = await readJson<Partial<ForgeCreateThreadInput>>(req)
315
+ if (typeof body.projectId !== 'string') throw badRequest('projectId is required')
316
+ requireProject(body.projectId)
317
+ if (!isHostId(body.host)) throw badRequest(`host must be one of: ${Object.keys(HOSTS).join(', ')}`)
318
+ const host = HOSTS[body.host]
319
+ const mode = body.mode === undefined ? host.defaultMode : requireHostMode(body.host, body.mode)
320
+ const model = optionalString(body.model, 'model') ?? host.defaultModel
321
+ const effort = optionalString(body.effort, 'effort')
322
+ const title = optionalString(body.title, 'title')
323
+ const { archived: _archived, ...thread } = store.insertThread({
324
+ projectId: body.projectId,
325
+ host: body.host,
326
+ model,
327
+ mode,
328
+ effort: effort ?? defaultEffortFor(body.host, model),
329
+ title: title ?? null,
330
+ })
331
+ return ok(thread)
332
+ })
333
+
334
+ route('GET', '/v1/forge/threads/:id', (_req, params) => {
335
+ const { archived: _archived, ...thread } = requireThread(params.id!)
336
+ return ok(thread)
337
+ })
338
+
339
+ route('PATCH', '/v1/forge/threads/:id', async (req, params) => {
340
+ const existing = requireThread(params.id!)
341
+ const body = await readJson<{ title?: unknown; model?: unknown; mode?: unknown; effort?: unknown }>(req)
342
+ const mode = body.mode === undefined ? undefined : requireHostMode(existing.host, body.mode)
343
+ const title = optionalString(body.title, 'title')
344
+ const model = optionalString(body.model, 'model')
345
+ const effort = optionalString(body.effort, 'effort')
346
+ const { archived: _archived, ...thread } = store.updateThread(params.id!, {
347
+ ...(title !== undefined ? { title } : {}),
348
+ ...(model !== undefined ? { model } : {}),
349
+ ...(mode !== undefined ? { mode } : {}),
350
+ ...(effort !== undefined ? { effort } : {}),
351
+ })
352
+ return ok(thread)
353
+ })
354
+
355
+ const deleteThread = async (id: string): Promise<Response> => {
356
+ requireThread(id)
357
+ const active = runs.activeRunFor(id)
358
+ if (active) await runs.cancel(active)
359
+ store.deleteThread(id)
360
+ return ok({ id })
361
+ }
362
+ route('DELETE', '/v1/forge/threads/:id', (_req, params) => deleteThread(params.id!))
363
+
364
+ // -- terminals ------------------------------------------------------------
365
+
366
+ // `?threadId=` is how the surface lists one session's tabs; unfiltered is
367
+ // every terminal the daemon owns, in the order they were opened.
368
+ route('GET', '/v1/forge/terminals', (_req, _params, url) => ok(terminals.list(url.searchParams.get('threadId'))))
369
+
370
+ route('POST', '/v1/forge/terminals', async (req) => {
371
+ const body = await readJson<Partial<ForgeCreateTerminalInput>>(req)
372
+ if (typeof body.projectId !== 'string') throw badRequest('projectId is required')
373
+ const project = requireProject(body.projectId)
374
+ if (body.threadId !== undefined && typeof body.threadId !== 'string') throw badRequest('threadId must be a string')
375
+ const title = optionalString(body.title, 'title')
376
+ return ok(
377
+ terminals.create({ ...body, projectId: body.projectId, ...(title ? { title } : {}) }, project),
378
+ )
379
+ })
380
+
381
+ route('PATCH', '/v1/forge/terminals/:id', async (req, params) => {
382
+ const body = await readJson<{ title?: unknown }>(req)
383
+ const title = optionalString(body.title, 'title')
384
+ if (!title) throw badRequest('title is required')
385
+ const renamed = terminals.rename(params.id!, title)
386
+ if (!renamed) throw notFound('terminal')
387
+ return ok(renamed)
388
+ })
389
+
390
+ route('DELETE', '/v1/forge/terminals/:id', (_req, params) => {
391
+ if (!terminals.kill(params.id!)) throw notFound('terminal')
392
+ return ok({ id: params.id! })
393
+ })
394
+
395
+ // -- chat (platform-compatible) ------------------------------------------
396
+
397
+ const sseResponse = (stream: ReadableStream<UIMessageChunk>, headers: Record<string, string>): Response => {
398
+ const res = createUIMessageStreamResponse({
399
+ stream: createUIMessageStream({
400
+ execute: ({ writer }) => {
401
+ writer.merge(stream)
402
+ },
403
+ }),
404
+ headers,
405
+ })
406
+ if (!res.body) return res
407
+ return new Response(withKeepalive(res.body), { status: res.status, headers: res.headers })
408
+ }
409
+
410
+ route('POST', '/v1/chat', async (req) => {
411
+ const body = await readJson<Partial<ForgeChatRequest>>(req)
412
+ /**
413
+ * A session is created by SENDING, not by opening a composer.
414
+ *
415
+ * The platform's chat works this way — `conversationId: 'new'` and the id
416
+ * comes back on the response header — and Forge matches it so the surface
417
+ * can reuse the same components and so an abandoned composer leaves no
418
+ * empty session in the list.
419
+ */
420
+ const isNew =
421
+ body.conversationId === undefined || body.conversationId === '' || body.conversationId === 'new'
422
+ let thread: StoredThread
423
+ if (isNew) {
424
+ if (typeof body.projectId !== 'string') throw badRequest('projectId is required to open a session')
425
+ requireProject(body.projectId)
426
+ const hostId = isHostId(body.host) ? body.host : firstInstalledHostId(opts.hostExec)
427
+ if (!hostId) throw new ForgeHttpError(409, 'no coding harness is installed — install Claude Code or Codex')
428
+ const host = HOSTS[hostId]
429
+ const model = optionalString(body.modelMode, 'modelMode') ?? host.defaultModel
430
+ thread = store.insertThread({
431
+ projectId: body.projectId,
432
+ host: hostId,
433
+ model,
434
+ mode: body.forgeMode === undefined ? host.defaultMode : requireHostMode(hostId, body.forgeMode),
435
+ effort: optionalString(body.reasoningEffort, 'reasoningEffort') ?? defaultEffortFor(hostId, model),
436
+ title: null,
437
+ })
438
+ } else {
439
+ thread = requireThread(body.conversationId as string)
440
+ }
441
+ const project = requireProject(thread.projectId)
442
+ if (runs.activeRunFor(thread.id)) throw new ForgeHttpError(409, 'a turn is already running on this thread')
443
+
444
+ const messages = Array.isArray(body.messages) ? body.messages : []
445
+ const lastUser = [...messages].reverse().find((m) => m && m.role === 'user')
446
+ if (!lastUser) throw badRequest('messages must contain a user message')
447
+ const rawText = (Array.isArray(lastUser.parts) ? lastUser.parts : [])
448
+ .filter((p) => p && p.type === 'text' && typeof p.text === 'string')
449
+ .map((p) => p.text as string)
450
+ .join('\n')
451
+ .trim()
452
+ const text = normalizeTurnText(rawText)
453
+ if (!text) throw badRequest('the user message has no text')
454
+
455
+ // The composer is the source of truth for model, effort and mode: what it
456
+ // sends applies to this turn AND sticks to the thread, exactly as the
457
+ // platform chat treats `modelMode`.
458
+ const patch: ThreadPatch = {}
459
+ if (typeof body.modelMode === 'string' && body.modelMode && body.modelMode !== thread.model) {
460
+ patch.model = body.modelMode
461
+ }
462
+ if (typeof body.reasoningEffort === 'string' && body.reasoningEffort && body.reasoningEffort !== thread.effort) {
463
+ patch.effort = body.reasoningEffort
464
+ }
465
+ if (body.forgeMode !== undefined) {
466
+ const mode = requireHostMode(thread.host, body.forgeMode)
467
+ if (mode !== thread.mode) patch.mode = mode
468
+ }
469
+ if (Object.keys(patch).length > 0) thread = store.updateThread(thread.id, patch)
470
+
471
+ const { runId, stream } = runs.start(thread, project, text)
472
+ return sseResponse(stream, {
473
+ [FORGE_HEADERS.conversationId]: thread.id,
474
+ [FORGE_HEADERS.runId]: runId,
475
+ [FORGE_HEADERS.agentRuntime]: 'wdk',
476
+ })
477
+ })
478
+
479
+ route('GET', '/v1/chat/:runId/stream', (_req, params, url) => {
480
+ const runId = params.runId!
481
+ const startIndex = Number(url.searchParams.get('startIndex') ?? '0')
482
+ const stream = runs.subscribe(runId, Number.isFinite(startIndex) ? startIndex : 0)
483
+ if (!stream) {
484
+ const stored = store.getRun(runId)
485
+ if (!stored) throw notFound('run')
486
+ // Finished before this daemon, or journal already released: nothing to replay.
487
+ throw new ForgeHttpError(410, 'this run is no longer streaming', 'RUN_FINISHED')
488
+ }
489
+ const run = store.getRun(runId)
490
+ return sseResponse(stream, {
491
+ ...(run ? { [FORGE_HEADERS.conversationId]: run.threadId } : {}),
492
+ [FORGE_HEADERS.runId]: runId,
493
+ [FORGE_HEADERS.agentRuntime]: 'wdk',
494
+ })
495
+ })
496
+
497
+ route('POST', '/v1/chat/:runId/cancel', async (_req, params) => {
498
+ const cancelled = await runs.cancel(params.runId!)
499
+ if (!cancelled) throw notFound('live run')
500
+ return ok({ runId: params.runId!, cancelled: true })
501
+ })
502
+
503
+ // -- sessions (platform-compatible) --------------------------------------
504
+
505
+ route('GET', '/v1/sessions/:id', (_req, params) => {
506
+ const thread = requireThread(params.id!)
507
+ return ok(sessionView(thread, runs.activeRunFor(thread.id)))
508
+ })
509
+
510
+ route('PATCH', '/v1/sessions/:id', async (req, params) => {
511
+ const id = params.id!
512
+ const existing = requireThread(id)
513
+ const body = await readJson<{
514
+ title?: unknown
515
+ modelMode?: unknown
516
+ status?: unknown
517
+ mode?: unknown
518
+ reasoningEffort?: unknown
519
+ }>(req)
520
+ const title = optionalString(body.title, 'title')
521
+ const modelMode = optionalString(body.modelMode, 'modelMode')
522
+ const effort = optionalString(body.reasoningEffort, 'reasoningEffort')
523
+ if (body.status !== undefined && body.status !== 'active' && body.status !== 'archived') {
524
+ throw badRequest('status must be active or archived')
525
+ }
526
+ const mode = body.mode === undefined ? undefined : requireHostMode(existing.host, body.mode)
527
+ const thread = store.updateThread(id, {
528
+ ...(title !== undefined ? { title } : {}),
529
+ ...(modelMode !== undefined ? { model: modelMode } : {}),
530
+ ...(effort !== undefined ? { effort } : {}),
531
+ ...(body.status !== undefined ? { archived: body.status === 'archived' } : {}),
532
+ ...(mode !== undefined ? { mode } : {}),
533
+ })
534
+ return ok(sessionView(thread, runs.activeRunFor(thread.id)))
535
+ })
536
+
537
+ route('DELETE', '/v1/sessions/:id', (_req, params) => deleteThread(params.id!))
538
+
539
+ route('GET', '/v1/sessions/:id/messages', (_req, params) => {
540
+ requireThread(params.id!)
541
+ return ok(store.listMessages(params.id!))
542
+ })
543
+
544
+ route('GET', '/v1/sessions/:id/pending', (_req, params) => {
545
+ requireThread(params.id!)
546
+ return ok([])
547
+ })
548
+
549
+ route('POST', '/v1/sessions/:id/approvals/:approvalId', async (req, params) => {
550
+ requireThread(params.id!)
551
+ const body = await readJson<{ decision?: unknown }>(req)
552
+ if (body.decision !== 'approve' && body.decision !== 'deny') throw badRequest('decision must be approve or deny')
553
+ if (!runs.respondApproval(params.id!, params.approvalId!, body.decision)) throw notFound('pending approval')
554
+ return ok({ approvalId: params.approvalId!, decision: body.decision })
555
+ })
556
+
557
+ route('GET', '/v1/follow-ups', () => ok([]))
558
+
559
+ // -- dispatch -------------------------------------------------------------
560
+
561
+ const authorized = (req: Request, url: URL): boolean => {
562
+ const header = req.headers.get('authorization')
563
+ if (header) return header === `Bearer ${opts.token}`
564
+ return url.searchParams.get('token') === opts.token
565
+ }
566
+
567
+ const server = Bun.serve<WsData>({
568
+ hostname: '127.0.0.1',
569
+ port: opts.port,
570
+ idleTimeout: IDLE_TIMEOUT_S,
571
+ async fetch(req, srv) {
572
+ const url = new URL(req.url)
573
+ const cors = corsHeaders()
574
+ const origin = req.headers.get('origin')
575
+ if (origin && origin !== opts.origin) {
576
+ return withHeaders(fail(403, 'origin not allowed', 'FORBIDDEN'), {})
577
+ }
578
+ if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors })
579
+
580
+ const segments = segmentsOf(url.pathname)
581
+
582
+ if (url.pathname !== FORGE_WELL_KNOWN_PATH && !authorized(req, url)) {
583
+ return withHeaders(fail(401, 'missing or invalid bearer token', 'UNAUTHORIZED'), cors)
584
+ }
585
+
586
+ // Terminal socket: /v1/forge/terminals/:id/ws
587
+ if (
588
+ segments.length === 5
589
+ && segments[0] === 'v1' && segments[1] === 'forge' && segments[2] === 'terminals' && segments[4] === 'ws'
590
+ ) {
591
+ const terminalId = decodeURIComponent(segments[3]!)
592
+ if (!terminals.get(terminalId)) return withHeaders(fail(404, 'terminal not found', 'NOT_FOUND'), cors)
593
+ const upgraded = srv.upgrade(req, { data: { terminalId, client: null } })
594
+ if (upgraded) return undefined as unknown as Response
595
+ return withHeaders(fail(400, 'expected a WebSocket upgrade', 'BAD_REQUEST'), cors)
596
+ }
597
+
598
+ for (const r of routes) {
599
+ const params = matchRoute(r, req.method, segments)
600
+ if (!params) continue
601
+ try {
602
+ return withHeaders(await r.handler(req, params, url), cors)
603
+ } catch (err) {
604
+ if (err instanceof ForgeHttpError) return withHeaders(fail(err.status, err.message, err.code), cors)
605
+ const message = err instanceof Error ? err.message : String(err)
606
+ log.error(`${req.method} ${url.pathname}: ${message}`)
607
+ return withHeaders(fail(500, message, 'INTERNAL_ERROR'), cors)
608
+ }
609
+ }
610
+ return withHeaders(fail(404, `no route for ${req.method} ${url.pathname}`, 'NOT_FOUND'), cors)
611
+ },
612
+ websocket: {
613
+ open(ws) {
614
+ const client: TerminalClient = {
615
+ sendBytes: (bytes) => ws.send(bytes),
616
+ sendJson: (message) => ws.send(JSON.stringify(message)),
617
+ }
618
+ ws.data.client = client
619
+ if (!terminals.attach(ws.data.terminalId, client)) ws.close(4004, 'terminal not found')
620
+ },
621
+ message(ws, message) {
622
+ const id = ws.data.terminalId
623
+ if (typeof message === 'string') {
624
+ let parsed: ForgeTerminalClientMessage
625
+ try {
626
+ parsed = JSON.parse(message) as ForgeTerminalClientMessage
627
+ } catch {
628
+ return
629
+ }
630
+ if (parsed.type === 'input' && typeof parsed.data === 'string') terminals.write(id, parsed.data)
631
+ else if (parsed.type === 'resize') terminals.resize(id, Number(parsed.cols), Number(parsed.rows))
632
+ return
633
+ }
634
+ terminals.write(id, new Uint8Array(message))
635
+ },
636
+ close(ws) {
637
+ if (ws.data.client) terminals.detach(ws.data.terminalId, ws.data.client)
638
+ },
639
+ },
640
+ })
641
+
642
+ // Optional in Bun's types because a unix-socket server has none; ours is TCP.
643
+ const port = server.port ?? opts.port
644
+ log.info(`listening on http://127.0.0.1:${port}`)
645
+
646
+ return {
647
+ port,
648
+ async stop() {
649
+ await runs.stopAll()
650
+ terminals.killAll()
651
+ server.stop(true)
652
+ store.close()
653
+ },
654
+ }
655
+ }