@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,349 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { basename, join, relative, resolve, sep } from 'node:path'
5
+
6
+ import type { ForgeChangeStatus, ForgeChangedFile, ForgeChanges, ForgeFileEntry } from '@frontera-sdk/forge-contracts'
7
+
8
+ import { findProjectRoot } from '../context'
9
+ import { readProject } from '../project'
10
+ import { applyClaudeImport, applyManagedBlock, type BlockMarkers } from '../kit'
11
+ import { ensureGitignored, findContext, writeContext } from '../project-context'
12
+ import { listProfiles, normalizeOrigin } from '../profiles'
13
+ import { badRequest } from './http'
14
+ import type { NewProject } from './store'
15
+
16
+ /** Directories nobody browses by hand, and that would swamp a listing. */
17
+ const SKIPPED_DIRS = new Set(['node_modules', '.git'])
18
+
19
+ /** Untracked files above this are counted as "large", not read line by line. */
20
+ const MAX_COUNTED_BYTES = 1024 * 1024
21
+
22
+ function git(root: string, args: string[]): { ok: boolean; out: string } {
23
+ const res = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
24
+ if (res.error || res.status !== 0) return { ok: false, out: '' }
25
+ return { ok: true, out: res.stdout ?? '' }
26
+ }
27
+
28
+ export function isGitWorkTree(root: string): boolean {
29
+ const res = git(root, ['rev-parse', '--is-inside-work-tree'])
30
+ return res.ok && res.out.trim() === 'true'
31
+ }
32
+
33
+ /** What a root is, detected fresh: a git tree, a Frontera App, a bound profile. */
34
+ export function inspectRoot(rawRoot: string): NewProject {
35
+ if (typeof rawRoot !== 'string' || rawRoot.trim() === '') throw badRequest('root is required')
36
+ const root = resolve(rawRoot)
37
+ let stat
38
+ try {
39
+ stat = statSync(root)
40
+ } catch {
41
+ throw badRequest(`${root} does not exist`)
42
+ }
43
+ if (!stat.isDirectory()) throw badRequest(`${root} is not a directory`)
44
+
45
+ let appSlug: string | null = null
46
+ if (findProjectRoot(root) === root) {
47
+ try {
48
+ appSlug = readProject(root).slug
49
+ } catch {
50
+ appSlug = null
51
+ }
52
+ }
53
+
54
+ let profile: string | null = null
55
+ try {
56
+ profile = findContext(root)?.profile ?? null
57
+ } catch {
58
+ // A corrupt context file is the CLI's problem to report, not a reason to
59
+ // refuse opening the folder.
60
+ profile = null
61
+ }
62
+
63
+ return { root, name: basename(root) || root, git: isGitWorkTree(root), appSlug, profile }
64
+ }
65
+
66
+ /**
67
+ * What is already true in this folder, written where both hosts read it.
68
+ *
69
+ * Codex reads `AGENTS.md` and Claude Code reads `CLAUDE.md` (which imports it)
70
+ * before the first turn, with no prompt injection and no per-turn cost — so
71
+ * this is the harness-native place to hand over the situation. It survives the
72
+ * developer running `claude` here from their own terminal, which a per-turn
73
+ * brief would not.
74
+ *
75
+ * FACTS, not instructions. The skills say what to do; a brief that argues with
76
+ * them produces a model that argues with both. Everything here is something
77
+ * Forge arranged and the agent would otherwise have to derive: which
78
+ * deployment, which workspace, which credential, what is already connected.
79
+ *
80
+ * Written as a managed block, so anything the developer adds to the same file
81
+ * stays where they put it.
82
+ */
83
+ const FORGE_MARKERS: BlockMarkers = {
84
+ begin: '<!-- frontera:forge:begin — managed by Frontera Forge; edits here are replaced -->',
85
+ end: '<!-- frontera:forge:end -->',
86
+ }
87
+
88
+ function situationBlock(input: {
89
+ workspaceName: string
90
+ workspaceId: string
91
+ apiUrl: string | null
92
+ profile: string | null
93
+ root: string
94
+ }): string {
95
+ const lines = [
96
+ '## Frontera Forge',
97
+ '',
98
+ `This folder is managed by Frontera Forge for the **${input.workspaceName}** workspace`
99
+ + `${input.apiUrl ? ` on ${input.apiUrl}` : ''}. It is not a checkout of a`
100
+ + " product repository — it is where this workspace's local work lives.",
101
+ '',
102
+ '- The `frontera` CLI is on PATH.'
103
+ + (input.profile
104
+ ? ` This directory is bound to the \`${input.profile}\` profile (\`.frontera/context.json\`), so commands here need no \`--profile\` or \`--api-url\`.`
105
+ : ' No profile is bound to this directory yet, so commands need `--profile` or `--api-url`.'),
106
+ '- The platform\'s tools are connected as the `frontera` MCP server: Apps,'
107
+ + ' Agents, Blueprint, Skills, Knowledge, Packs and navigation are available'
108
+ + ' as `mcp__frontera__*` tools. Prefer them over guessing at platform state,'
109
+ + ' and over shelling out for something a tool already answers.',
110
+ '- The Frontera plugin is installed in this host, so the `frontera:*` skills'
111
+ + ' (what the artifacts are, how to author and publish them) load on demand.',
112
+ '- App source belongs at `./<slug>`, pulled with'
113
+ + ' `frontera app pull <slug> --dir ./<slug>`.',
114
+ '',
115
+ `Workspace id \`${input.workspaceId}\`.`,
116
+ ]
117
+ return lines.join('\n')
118
+ }
119
+
120
+ /**
121
+ * Write (or refresh) the situation into `AGENTS.md` + `CLAUDE.md`.
122
+ *
123
+ * Idempotent and cheap: both hosts re-read the files each session, so keeping
124
+ * them current on every folder resolve is how a workspace rename or a
125
+ * re-binding reaches the agent without anyone remembering to do it.
126
+ */
127
+ function writeSituationFiles(root: string, body: string): void {
128
+ try {
129
+ const agentsPath = join(root, 'AGENTS.md')
130
+ const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : null
131
+ const next = applyManagedBlock(existing, body, FORGE_MARKERS)
132
+ if (next !== existing) writeFileSync(agentsPath, next)
133
+
134
+ const claudePath = join(root, 'CLAUDE.md')
135
+ const claudeExisting = existsSync(claudePath) ? readFileSync(claudePath, 'utf8') : null
136
+ const { content } = applyClaudeImport(claudeExisting, '@AGENTS.md')
137
+ if (content !== claudeExisting) writeFileSync(claudePath, content)
138
+ } catch {
139
+ // A read-only folder is the developer's business; the session still works.
140
+ }
141
+ }
142
+
143
+ /** Where Forge keeps a workspace's folder. Visible on purpose: developers open it in editors. */
144
+ export function workspaceRoot(workspaceSlug: string): string {
145
+ const safe = workspaceSlug.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'workspace'
146
+ return join(homedir(), 'Frontera', safe)
147
+ }
148
+
149
+ /** The profile bound to this deployment's API origin, if the CLI has one. */
150
+ function profileForOrigin(apiUrl: string | null): string | null {
151
+ if (!apiUrl) return null
152
+ let origin: string
153
+ try {
154
+ origin = normalizeOrigin(apiUrl)
155
+ } catch {
156
+ return null
157
+ }
158
+ try {
159
+ const match = listProfiles().find((p) => {
160
+ try {
161
+ return normalizeOrigin(p.profile.apiUrl) === origin
162
+ } catch {
163
+ return false
164
+ }
165
+ })
166
+ return match?.name ?? null
167
+ } catch {
168
+ return null
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Create-or-adopt the managed folder for a workspace: mkdir, `git init` so
174
+ * Changes has a baseline, and bind the deployment's profile so `frontera`
175
+ * and the host's Frontera MCP resolve there without anyone choosing.
176
+ * Idempotent; a folder the developer already turned into a repo or bound
177
+ * to a profile is left as it is.
178
+ */
179
+ export function ensureWorkspaceProject(
180
+ input: { workspaceId: string; workspaceSlug: string; workspaceName?: string },
181
+ apiUrl: string | null,
182
+ warn: (message: string) => void = () => {},
183
+ ): NewProject {
184
+ const root = workspaceRoot(input.workspaceSlug)
185
+ mkdirSync(root, { recursive: true })
186
+ if (!isGitWorkTree(root)) {
187
+ spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' })
188
+ }
189
+ let bound: string | null = null
190
+ try {
191
+ bound = findContext(root)?.profile ?? null
192
+ } catch {
193
+ bound = null
194
+ }
195
+ if (!bound) {
196
+ const profile = profileForOrigin(apiUrl)
197
+ if (!profile) {
198
+ warn(`no CLI profile for ${apiUrl ?? '(no api url)'}; ${root} left unbound`)
199
+ } else {
200
+ try {
201
+ writeContext(root, profile)
202
+ if (existsSync(join(root, '.git'))) ensureGitignored(root)
203
+ } catch (error) {
204
+ // Binding is a convenience; the folder still works without it.
205
+ warn(`could not bind ${root} to profile ${profile}: ${error instanceof Error ? error.message : String(error)}`)
206
+ }
207
+ }
208
+ }
209
+ const detected = inspectRoot(root)
210
+ const name = input.workspaceName?.trim() || detected.name
211
+ writeSituationFiles(
212
+ root,
213
+ situationBlock({
214
+ workspaceName: name,
215
+ workspaceId: input.workspaceId,
216
+ apiUrl,
217
+ profile: detected.profile,
218
+ root,
219
+ }),
220
+ )
221
+ return { ...detected, name, workspaceId: input.workspaceId }
222
+ }
223
+
224
+ /** One level of a project tree, directories first. Never escapes the root. */
225
+ export function listFiles(root: string, rel: string): ForgeFileEntry[] {
226
+ const target = resolve(root, rel || '.')
227
+ const inside = target === root || target.startsWith(root + sep)
228
+ if (!inside) throw badRequest('path escapes the project root')
229
+ let entries
230
+ try {
231
+ entries = readdirSync(target, { withFileTypes: true })
232
+ } catch {
233
+ throw badRequest(`${relative(root, target) || '.'} is not a readable directory`)
234
+ }
235
+ const out: ForgeFileEntry[] = []
236
+ for (const entry of entries) {
237
+ if (SKIPPED_DIRS.has(entry.name)) continue
238
+ const abs = join(target, entry.name)
239
+ const path = relative(root, abs).split(sep).join('/')
240
+ if (entry.isDirectory()) {
241
+ out.push({ name: entry.name, path, kind: 'dir' })
242
+ } else {
243
+ let size: number | undefined
244
+ try {
245
+ size = statSync(abs).size
246
+ } catch {
247
+ size = undefined
248
+ }
249
+ out.push({ name: entry.name, path, kind: 'file', ...(size !== undefined ? { size } : {}) })
250
+ }
251
+ }
252
+ return out.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1))
253
+ }
254
+
255
+ function statusFor(xy: string): ForgeChangeStatus | null {
256
+ if (xy === '??') return 'untracked'
257
+ const x = xy[0] ?? ' '
258
+ const y = xy[1] ?? ' '
259
+ const code = x !== ' ' ? x : y
260
+ switch (code) {
261
+ case 'A':
262
+ return 'added'
263
+ case 'D':
264
+ return 'deleted'
265
+ case 'R':
266
+ case 'C':
267
+ return 'renamed'
268
+ case 'M':
269
+ case 'T':
270
+ case 'U':
271
+ return 'modified'
272
+ default:
273
+ return null
274
+ }
275
+ }
276
+
277
+ function countLines(abs: string): number {
278
+ try {
279
+ const stat = statSync(abs)
280
+ if (!stat.isFile() || stat.size > MAX_COUNTED_BYTES) return 0
281
+ const buf = readFileSync(abs)
282
+ // A NUL in the first block is the same heuristic git uses for "binary".
283
+ if (buf.subarray(0, 8000).includes(0)) return 0
284
+ let n = 0
285
+ for (const byte of buf) if (byte === 10) n += 1
286
+ if (buf.length > 0 && buf[buf.length - 1] !== 10) n += 1
287
+ return n
288
+ } catch {
289
+ return 0
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Parse `git diff --numstat`: `added\tdeleted\tpath`, with `-` for binary and
295
+ * `old => new` (or `{a => b}/rest`) paths for renames. The rename form is
296
+ * folded to the new path so it matches the status listing.
297
+ */
298
+ function parseNumstat(out: string): Map<string, { additions: number; deletions: number }> {
299
+ const map = new Map<string, { additions: number; deletions: number }>()
300
+ for (const line of out.split('\n')) {
301
+ if (!line) continue
302
+ const [a, d, ...rest] = line.split('\t')
303
+ const rawPath = rest.join('\t')
304
+ if (!rawPath) continue
305
+ const path = rawPath.includes(' => ')
306
+ ? rawPath.replace(/\{([^{}]*) => ([^{}]*)\}/g, '$2').replace(/^.* => /, '')
307
+ : rawPath
308
+ map.set(path, { additions: a === '-' ? 0 : Number(a) || 0, deletions: d === '-' ? 0 : Number(d) || 0 })
309
+ }
310
+ return map
311
+ }
312
+
313
+ /** Working tree vs HEAD. Not a git tree, or no commits yet: as much as exists. */
314
+ export function readChanges(root: string): ForgeChanges {
315
+ if (!isGitWorkTree(root)) return { files: [], patch: '', head: null, branch: null }
316
+
317
+ const headRes = git(root, ['rev-parse', 'HEAD'])
318
+ const head = headRes.ok ? headRes.out.trim() : null
319
+ const branchRes = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
320
+ const branch = branchRes.ok ? branchRes.out.trim() : null
321
+
322
+ // Before the first commit there is no HEAD to diff against; the index is
323
+ // the closest thing to a baseline.
324
+ const base = head ? ['HEAD'] : []
325
+ const numstat = parseNumstat(git(root, ['diff', ...base, '--numstat']).out)
326
+ const patch = git(root, ['diff', ...base]).out
327
+
328
+ const files: ForgeChangedFile[] = []
329
+ const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
330
+ const entries = status.out.split('\0')
331
+ for (let i = 0; i < entries.length; i += 1) {
332
+ const entry = entries[i]
333
+ if (!entry || entry.length < 4) continue
334
+ const xy = entry.slice(0, 2)
335
+ const path = entry.slice(3)
336
+ const kind = statusFor(xy)
337
+ // A rename carries its origin as the next NUL-separated field.
338
+ if (xy[0] === 'R' || xy[0] === 'C') i += 1
339
+ if (!kind) continue
340
+ if (kind === 'untracked') {
341
+ files.push({ path, status: kind, additions: countLines(join(root, path)), deletions: 0 })
342
+ continue
343
+ }
344
+ const counts = numstat.get(path) ?? { additions: 0, deletions: 0 }
345
+ files.push({ path, status: kind, ...counts })
346
+ }
347
+
348
+ return { files, patch, head, branch }
349
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The handful of wire constants the daemon needs at RUNTIME.
3
+ *
4
+ * They are duplicated from `@frontera-sdk/forge-contracts` on purpose. That
5
+ * package is private and internal — it is the shared type surface for the
6
+ * desktop shell and the web surface — while this CLI is published to npm. A
7
+ * published package that depends on an unpublished one resolves nowhere:
8
+ * `bunx @frontera-sdk/cli` would fail for every consumer, which is the exact
9
+ * breakage the release workflow's `workspace:` gate exists to catch.
10
+ *
11
+ * So the contracts package stays a devDependency here, imported for types
12
+ * only (erased at build), and the four values live here. `protocol.test.ts`
13
+ * asserts the two agree, so the duplication cannot drift silently.
14
+ */
15
+
16
+ /** Response headers the platform's chat client reads. */
17
+ export const FORGE_HEADERS = {
18
+ conversationId: 'X-Conversation-Id',
19
+ runId: 'x-workflow-run-id',
20
+ agentRuntime: 'x-agent-runtime',
21
+ } as const
22
+
23
+ /** Unauthenticated readiness probe the desktop shell polls. */
24
+ export const FORGE_WELL_KNOWN_PATH = '/.well-known/frontera/forge'
25
+
26
+ /** Run ids carry this so the web treats them as non-steerable runs. */
27
+ export const FORGE_RUN_ID_PREFIX = 'forge_'
28
+
29
+ /** Platform chat affordances that have no meaning against a local harness. */
30
+ export const FORGE_DISABLED_CAPABILITIES = [
31
+ 'fork',
32
+ 'share',
33
+ 'export',
34
+ 'schedule',
35
+ 'labels',
36
+ 'canvas',
37
+ 'overview',
38
+ ] as const
@@ -0,0 +1,250 @@
1
+ import type { ForgeProject } from '@frontera-sdk/forge-contracts'
2
+
3
+ import { FORGE_RUN_ID_PREFIX } from './protocol'
4
+ import { readUIMessageStream, type UIMessage, type UIMessageChunk } from 'ai'
5
+
6
+ import type { AdapterFactory, ApprovalDecision, HostAdapter } from './adapter'
7
+ import type { Logger } from './log'
8
+ import type { ForgeStore, StoredThread } from './store'
9
+
10
+ /**
11
+ * Live turns. Each run keeps an append-only chunk journal in memory and fans
12
+ * every chunk out to whoever is streaming it — the response to `POST /v1/chat`
13
+ * and any `GET /v1/chat/:runId/stream` that reconnected after a reload. When
14
+ * the host finishes, the journal is folded into one assistant `UIMessage`
15
+ * and persisted; the journal itself is dropped.
16
+ */
17
+
18
+ interface Subscriber {
19
+ push(chunk: UIMessageChunk): void
20
+ close(): void
21
+ }
22
+
23
+ interface LiveRun {
24
+ id: string
25
+ threadId: string
26
+ messageId: string
27
+ journal: UIMessageChunk[]
28
+ subscribers: Set<Subscriber>
29
+ finished: boolean
30
+ adapter: HostAdapter
31
+ abort: AbortController
32
+ }
33
+
34
+ /** Thread titles are the first line of the first message, cut short. */
35
+ const TITLE_LENGTH = 60
36
+
37
+ export function titleFrom(text: string): string {
38
+ const line = text.split('\n').find((l) => l.trim() !== '') ?? text
39
+ const trimmed = line.trim()
40
+ return trimmed.length > TITLE_LENGTH ? `${trimmed.slice(0, TITLE_LENGTH - 1)}…` : trimmed
41
+ }
42
+
43
+ /**
44
+ * Fold a chunk journal into the message the AI SDK would have built on the
45
+ * client. The reducer throws on a malformed sequence (a result for a call it
46
+ * never saw); the fallback keeps the text so a turn is never lost entirely.
47
+ */
48
+ export async function assembleMessage(messageId: string, journal: UIMessageChunk[]): Promise<UIMessage> {
49
+ const stream = new ReadableStream<UIMessageChunk>({
50
+ start(controller) {
51
+ for (const chunk of journal) controller.enqueue(chunk)
52
+ controller.close()
53
+ },
54
+ })
55
+ let last: UIMessage = { id: messageId, role: 'assistant', parts: [] }
56
+ try {
57
+ for await (const message of readUIMessageStream({ message: last, stream, onError: () => {}, terminateOnError: false })) {
58
+ last = message
59
+ }
60
+ return { ...last, id: messageId, role: 'assistant' }
61
+ } catch {
62
+ return fallbackMessage(messageId, journal)
63
+ }
64
+ }
65
+
66
+ function fallbackMessage(messageId: string, journal: UIMessageChunk[]): UIMessage {
67
+ const text = journal
68
+ .map((chunk) => (chunk.type === 'text-delta' ? chunk.delta : ''))
69
+ .join('')
70
+ const error = journal.find((chunk) => chunk.type === 'error')
71
+ const parts: UIMessage['parts'] = []
72
+ if (text) parts.push({ type: 'text', text, state: 'done' })
73
+ if (error && error.type === 'error') parts.push({ type: 'text', text: `Error: ${error.errorText}`, state: 'done' })
74
+ return { id: messageId, role: 'assistant', parts }
75
+ }
76
+
77
+ export class RunManager {
78
+ private readonly live = new Map<string, LiveRun>()
79
+
80
+ constructor(
81
+ private readonly store: ForgeStore,
82
+ private readonly adapters: AdapterFactory,
83
+ private readonly log: Logger,
84
+ private readonly apiUrl: string,
85
+ ) {}
86
+
87
+ activeRunFor(threadId: string): string | null {
88
+ for (const run of this.live.values()) if (run.threadId === threadId && !run.finished) return run.id
89
+ return null
90
+ }
91
+
92
+ /** Persist the user's message, open a run, start the host. Returns the live stream. */
93
+ start(thread: StoredThread, project: ForgeProject, text: string): { runId: string; stream: ReadableStream<UIMessageChunk> } {
94
+ const runId = `${FORGE_RUN_ID_PREFIX}${crypto.randomUUID()}`
95
+ const messageId = crypto.randomUUID()
96
+
97
+ this.store.appendMessage(thread.id, {
98
+ id: crypto.randomUUID(),
99
+ role: 'user',
100
+ parts: [{ type: 'text', text }],
101
+ })
102
+ this.store.insertRun(runId, thread.id)
103
+ const running = this.store.updateThread(thread.id, {
104
+ status: 'running',
105
+ activeRunId: runId,
106
+ ...(thread.title === null ? { title: titleFrom(text) } : {}),
107
+ })
108
+
109
+ const run: LiveRun = {
110
+ id: runId,
111
+ threadId: thread.id,
112
+ messageId,
113
+ journal: [],
114
+ subscribers: new Set(),
115
+ finished: false,
116
+ adapter: this.adapters(thread.host),
117
+ abort: new AbortController(),
118
+ }
119
+ this.live.set(runId, run)
120
+
121
+ void this.execute(run, running, project, text)
122
+ return { runId, stream: this.streamFrom(run, 0) }
123
+ }
124
+
125
+ /** Replay from `startIndex`, then live until the run finishes. Null when the run is not live. */
126
+ subscribe(runId: string, startIndex: number): ReadableStream<UIMessageChunk> | null {
127
+ const run = this.live.get(runId)
128
+ if (!run) return null
129
+ return this.streamFrom(run, Math.max(0, startIndex))
130
+ }
131
+
132
+ async cancel(runId: string): Promise<boolean> {
133
+ const run = this.live.get(runId)
134
+ if (!run || run.finished) return false
135
+ run.abort.abort()
136
+ await run.adapter.interrupt()
137
+ return true
138
+ }
139
+
140
+ respondApproval(threadId: string, approvalId: string, decision: ApprovalDecision): boolean {
141
+ for (const run of this.live.values()) {
142
+ if (run.threadId !== threadId || run.finished) continue
143
+ if (run.adapter.respondApproval(approvalId, decision)) return true
144
+ }
145
+ return false
146
+ }
147
+
148
+ /** Interrupt every live run — daemon shutdown. */
149
+ async stopAll(): Promise<void> {
150
+ await Promise.all([...this.live.keys()].map((id) => this.cancel(id)))
151
+ }
152
+
153
+ private streamFrom(run: LiveRun, startIndex: number): ReadableStream<UIMessageChunk> {
154
+ let subscriber: Subscriber | null = null
155
+ return new ReadableStream<UIMessageChunk>({
156
+ start(controller) {
157
+ // Replay and subscribe in one synchronous step, so no chunk can land
158
+ // between the slice and the subscription.
159
+ for (const chunk of run.journal.slice(startIndex)) controller.enqueue(chunk)
160
+ if (run.finished) {
161
+ controller.close()
162
+ return
163
+ }
164
+ subscriber = {
165
+ push: (chunk) => {
166
+ try {
167
+ controller.enqueue(chunk)
168
+ } catch {
169
+ // Consumer went away; the close below removes us.
170
+ }
171
+ },
172
+ close: () => {
173
+ try {
174
+ controller.close()
175
+ } catch {
176
+ // Already closed.
177
+ }
178
+ },
179
+ }
180
+ run.subscribers.add(subscriber)
181
+ },
182
+ cancel() {
183
+ if (subscriber) run.subscribers.delete(subscriber)
184
+ },
185
+ })
186
+ }
187
+
188
+ private async execute(run: LiveRun, thread: StoredThread, project: ForgeProject, text: string): Promise<void> {
189
+ const emit = (chunk: UIMessageChunk) => {
190
+ if (run.finished) return
191
+ run.journal.push(chunk)
192
+ for (const sub of run.subscribers) sub.push(chunk)
193
+ }
194
+
195
+ let outcome: { error: string | null }
196
+ try {
197
+ outcome = await run.adapter.startTurn(
198
+ {
199
+ runId: run.id,
200
+ threadId: thread.id,
201
+ messageId: run.messageId,
202
+ text,
203
+ project,
204
+ thread,
205
+ apiUrl: this.apiUrl,
206
+ signal: run.abort.signal,
207
+ setHostSessionId: (id) => {
208
+ this.store.updateThread(thread.id, { hostSessionId: id })
209
+ },
210
+ setThreadStatus: (status) => {
211
+ if (this.store.getThread(thread.id)) this.store.updateThread(thread.id, { status })
212
+ },
213
+ },
214
+ emit,
215
+ )
216
+ } catch (err) {
217
+ const message = err instanceof Error ? err.message : String(err)
218
+ this.log.error(`run ${run.id} crashed: ${message}`)
219
+ outcome = { error: message }
220
+ if (!run.journal.some((c) => c.type === 'start')) emit({ type: 'start', messageId: run.messageId } as UIMessageChunk)
221
+ emit({ type: 'error', errorText: message } as UIMessageChunk)
222
+ emit({ type: 'finish' } as UIMessageChunk)
223
+ }
224
+
225
+ if (!run.journal.some((c) => c.type === 'finish')) emit({ type: 'finish' } as UIMessageChunk)
226
+ run.finished = true
227
+ for (const sub of run.subscribers) sub.close()
228
+ run.subscribers.clear()
229
+
230
+ const cancelled = run.abort.signal.aborted
231
+ try {
232
+ const message = await assembleMessage(run.messageId, run.journal)
233
+ // A thread deleted mid-run has nowhere to put the message.
234
+ if (this.store.getThread(thread.id)) {
235
+ this.store.appendMessage(thread.id, message)
236
+ this.store.updateThread(thread.id, {
237
+ status: outcome.error && !cancelled ? 'error' : 'idle',
238
+ activeRunId: null,
239
+ })
240
+ }
241
+ this.store.finishRun(run.id, cancelled ? 'cancelled' : outcome.error ? 'error' : 'completed', outcome.error)
242
+ } catch (err) {
243
+ this.log.error(`run ${run.id} could not be persisted: ${err instanceof Error ? err.message : String(err)}`)
244
+ } finally {
245
+ // Keep the journal briefly so a client that reconnects right at the end
246
+ // can still replay the tail, then let it go.
247
+ setTimeout(() => this.live.delete(run.id), 30_000).unref()
248
+ }
249
+ }
250
+ }