@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.
- package/package.json +6 -3
- package/src/commands/forge/index-commands.ts +4 -0
- package/src/commands/forge/serve.ts +144 -0
- package/src/commands/registry.ts +3 -0
- package/src/flag-help.ts +7 -1
- package/src/forge/adapter.ts +108 -0
- package/src/forge/claude-adapter.ts +205 -0
- package/src/forge/claude-commands.ts +136 -0
- package/src/forge/claude-skills.ts +189 -0
- package/src/forge/claude-translator.ts +361 -0
- package/src/forge/codex-adapter.ts +28 -0
- package/src/forge/env.ts +42 -0
- package/src/forge/hosts.ts +323 -0
- package/src/forge/http.ts +74 -0
- package/src/forge/log.ts +53 -0
- package/src/forge/projects.ts +349 -0
- package/src/forge/protocol.ts +38 -0
- package/src/forge/runs.ts +250 -0
- package/src/forge/server.ts +655 -0
- package/src/forge/sessions.ts +49 -0
- package/src/forge/store.ts +427 -0
- package/src/forge/terminals.ts +220 -0
- package/src/forge/turn-text.ts +35 -0
- package/src/kit.ts +25 -7
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -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
|
+
}
|