@frontera-sdk/cli 1.46.6 → 1.47.0

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,49 @@
1
+ import type { ForgeSessionView } from '@frontera-sdk/forge-contracts'
2
+
3
+ import { FORGE_DISABLED_CAPABILITIES } from './protocol'
4
+
5
+ import { HOSTS, modelsFor } from './hosts'
6
+ import type { StoredThread } from './store'
7
+
8
+ /**
9
+ * A Forge thread presented as a platform session, so `SessionPageContent`
10
+ * and the session hooks render it without knowing they are talking to a
11
+ * daemon. Only the fields those components read carry real values; the rest
12
+ * are the platform's neutral defaults.
13
+ *
14
+ * The permission mode travels under `forge.mode` in the host's own vocabulary,
15
+ * alongside the host's full list — there is no platform-side mode to map onto.
16
+ */
17
+ export function sessionView(thread: StoredThread, activeRunId: string | null): ForgeSessionView {
18
+ const host = HOSTS[thread.host]
19
+ return {
20
+ id: thread.id,
21
+ title: thread.title,
22
+ preview: null,
23
+ status: thread.archived ? 'archived' : 'active',
24
+ pinned: false,
25
+ pinnedAt: null,
26
+ modelMode: thread.model,
27
+ runtime: 'forge',
28
+ activeRunId,
29
+ activePlan: null,
30
+ agentName: 'forge',
31
+ agentConfigId: null,
32
+ agentDisplayName: 'Forge',
33
+ models: modelsFor(thread.host, thread.model),
34
+ labels: [],
35
+ workspaceId: null,
36
+ createdAt: thread.createdAt,
37
+ updatedAt: thread.updatedAt,
38
+ forge: {
39
+ projectId: thread.projectId,
40
+ host: thread.host,
41
+ hostLabel: host.label,
42
+ mode: thread.mode,
43
+ modes: host.modes,
44
+ effort: thread.effort,
45
+ hostSessionId: thread.hostSessionId,
46
+ disabled: FORGE_DISABLED_CAPABILITIES,
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,427 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ import type { ForgeHostId, ForgeProject, ForgeThread, ForgeThreadStatus } from '@frontera-sdk/forge-contracts'
6
+ import type { UIMessage } from 'ai'
7
+
8
+ /**
9
+ * Local state the daemon owns. Nothing here is uploaded to the platform.
10
+ *
11
+ * Synchronous on purpose: bun:sqlite is synchronous, the daemon serves one
12
+ * developer, and every query is a point lookup or a short list. An async
13
+ * facade would add await sites without adding concurrency.
14
+ */
15
+
16
+ const SCHEMA = `
17
+ CREATE TABLE IF NOT EXISTS projects (
18
+ id TEXT PRIMARY KEY,
19
+ root TEXT NOT NULL UNIQUE,
20
+ name TEXT NOT NULL,
21
+ profile TEXT,
22
+ app_slug TEXT,
23
+ git INTEGER NOT NULL DEFAULT 0,
24
+ workspace_id TEXT,
25
+ created_at TEXT NOT NULL,
26
+ last_opened_at TEXT NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS threads (
29
+ id TEXT PRIMARY KEY,
30
+ project_id TEXT NOT NULL,
31
+ host TEXT NOT NULL,
32
+ model TEXT,
33
+ mode TEXT NOT NULL,
34
+ effort TEXT,
35
+ title TEXT,
36
+ status TEXT NOT NULL,
37
+ host_session_id TEXT,
38
+ active_run_id TEXT,
39
+ archived INTEGER NOT NULL DEFAULT 0,
40
+ created_at TEXT NOT NULL,
41
+ updated_at TEXT NOT NULL
42
+ );
43
+ CREATE INDEX IF NOT EXISTS threads_project ON threads(project_id, updated_at);
44
+ CREATE TABLE IF NOT EXISTS messages (
45
+ id TEXT PRIMARY KEY,
46
+ thread_id TEXT NOT NULL,
47
+ seq INTEGER NOT NULL,
48
+ role TEXT NOT NULL,
49
+ json TEXT NOT NULL
50
+ );
51
+ CREATE INDEX IF NOT EXISTS messages_thread ON messages(thread_id, seq);
52
+ CREATE TABLE IF NOT EXISTS runs (
53
+ id TEXT PRIMARY KEY,
54
+ thread_id TEXT NOT NULL,
55
+ status TEXT NOT NULL,
56
+ started_at TEXT NOT NULL,
57
+ finished_at TEXT,
58
+ error TEXT
59
+ );
60
+ `
61
+
62
+ interface ProjectRow {
63
+ id: string
64
+ root: string
65
+ name: string
66
+ profile: string | null
67
+ app_slug: string | null
68
+ git: number
69
+ workspace_id: string | null
70
+ created_at: string
71
+ last_opened_at: string
72
+ }
73
+
74
+ interface ThreadRow {
75
+ id: string
76
+ project_id: string
77
+ host: string
78
+ model: string | null
79
+ mode: string | null
80
+ effort: string | null
81
+ /** Only on stores created before host-native modes; see the migration. */
82
+ runtime_mode?: string
83
+ title: string | null
84
+ status: string
85
+ host_session_id: string | null
86
+ active_run_id: string | null
87
+ archived: number
88
+ created_at: string
89
+ updated_at: string
90
+ }
91
+
92
+ interface MessageRow {
93
+ id: string
94
+ seq: number
95
+ json: string
96
+ }
97
+
98
+ interface RunRow {
99
+ id: string
100
+ thread_id: string
101
+ status: string
102
+ started_at: string
103
+ finished_at: string | null
104
+ error: string | null
105
+ }
106
+
107
+ /** A thread as stored: the wire shape plus the archive flag sessions expose. */
108
+ export interface StoredThread extends ForgeThread {
109
+ archived: boolean
110
+ }
111
+
112
+ export type RunStatus = 'running' | 'completed' | 'error' | 'cancelled'
113
+
114
+ export interface StoredRun {
115
+ id: string
116
+ threadId: string
117
+ status: RunStatus
118
+ startedAt: string
119
+ finishedAt: string | null
120
+ error: string | null
121
+ }
122
+
123
+ export interface NewProject {
124
+ root: string
125
+ name: string
126
+ profile: string | null
127
+ appSlug: string | null
128
+ git: boolean
129
+ workspaceId?: string | null
130
+ }
131
+
132
+ export interface NewThread {
133
+ projectId: string
134
+ host: ForgeHostId
135
+ model: string | null
136
+ /** Host-native permission mode id. */
137
+ mode: string
138
+ effort: string | null
139
+ title: string | null
140
+ }
141
+
142
+ export type ThreadPatch = Partial<
143
+ Pick<StoredThread, 'title' | 'model' | 'mode' | 'effort' | 'status' | 'hostSessionId' | 'activeRunId' | 'archived'>
144
+ >
145
+
146
+ /**
147
+ * What a pre-modes store called this mode. Only written to keep the old
148
+ * NOT NULL `runtime_mode` column satisfied on a database this daemon
149
+ * inherited; nothing reads it back.
150
+ */
151
+ function legacyRuntimeMode(mode: string): string {
152
+ switch (mode) {
153
+ case 'acceptEdits':
154
+ return 'auto-accept-edits'
155
+ case 'bypassPermissions':
156
+ return 'full-access'
157
+ default:
158
+ return 'approval-required'
159
+ }
160
+ }
161
+
162
+ function now(): string {
163
+ return new Date().toISOString()
164
+ }
165
+
166
+ function projectFromRow(row: ProjectRow): ForgeProject {
167
+ return {
168
+ id: row.id,
169
+ root: row.root,
170
+ name: row.name,
171
+ profile: row.profile,
172
+ appSlug: row.app_slug,
173
+ git: row.git === 1,
174
+ workspaceId: row.workspace_id,
175
+ createdAt: row.created_at,
176
+ lastOpenedAt: row.last_opened_at,
177
+ }
178
+ }
179
+
180
+ function threadFromRow(row: ThreadRow): StoredThread {
181
+ return {
182
+ id: row.id,
183
+ projectId: row.project_id,
184
+ host: row.host as ForgeHostId,
185
+ model: row.model,
186
+ mode: row.mode ?? 'default',
187
+ effort: row.effort,
188
+ title: row.title,
189
+ status: row.status as ForgeThreadStatus,
190
+ hostSessionId: row.host_session_id,
191
+ activeRunId: row.active_run_id,
192
+ archived: row.archived === 1,
193
+ createdAt: row.created_at,
194
+ updatedAt: row.updated_at,
195
+ }
196
+ }
197
+
198
+ function runFromRow(row: RunRow): StoredRun {
199
+ return {
200
+ id: row.id,
201
+ threadId: row.thread_id,
202
+ status: row.status as RunStatus,
203
+ startedAt: row.started_at,
204
+ finishedAt: row.finished_at,
205
+ error: row.error,
206
+ }
207
+ }
208
+
209
+ export class ForgeStore {
210
+ private readonly db: Database
211
+ /** A store from before host-native modes still carries a NOT NULL column. */
212
+ private readonly hasLegacyRuntimeMode: boolean
213
+
214
+ constructor(path: string) {
215
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
216
+ this.db = new Database(path, { create: true })
217
+ if (path !== ':memory:') this.db.exec('PRAGMA journal_mode = WAL')
218
+ this.db.exec(SCHEMA)
219
+ // Additive migration for stores created before workspace-managed folders.
220
+ try {
221
+ this.db.exec('ALTER TABLE projects ADD COLUMN workspace_id TEXT')
222
+ } catch {
223
+ // Column already present.
224
+ }
225
+ // Additive migration for stores created before host-native modes, when a
226
+ // thread carried a Forge `runtime_mode` instead of the host's own id.
227
+ try {
228
+ this.db.exec('ALTER TABLE threads ADD COLUMN mode TEXT')
229
+ } catch {
230
+ // Column already present.
231
+ }
232
+ try {
233
+ this.db.exec('ALTER TABLE threads ADD COLUMN effort TEXT')
234
+ } catch {
235
+ // Column already present.
236
+ }
237
+ try {
238
+ this.db.exec(
239
+ "UPDATE threads SET mode = CASE runtime_mode"
240
+ + " WHEN 'approval-required' THEN 'default'"
241
+ + " WHEN 'auto-accept-edits' THEN 'acceptEdits'"
242
+ + " WHEN 'auto' THEN 'acceptEdits'"
243
+ + " WHEN 'full-access' THEN 'bypassPermissions'"
244
+ + " ELSE 'default' END WHERE mode IS NULL",
245
+ )
246
+ } catch {
247
+ // No `runtime_mode` column: this store was created with `mode` already.
248
+ }
249
+ this.hasLegacyRuntimeMode = this.db
250
+ .query<{ name: string }, []>('PRAGMA table_info(threads)')
251
+ .all()
252
+ .some((c) => c.name === 'runtime_mode')
253
+ }
254
+
255
+ close(): void {
256
+ this.db.close()
257
+ }
258
+
259
+ // -- projects -------------------------------------------------------------
260
+
261
+ listProjects(): ForgeProject[] {
262
+ return this.db
263
+ .query<ProjectRow, []>('SELECT * FROM projects ORDER BY last_opened_at DESC')
264
+ .all()
265
+ .map(projectFromRow)
266
+ }
267
+
268
+ getProject(id: string): ForgeProject | null {
269
+ const row = this.db.query<ProjectRow, [string]>('SELECT * FROM projects WHERE id = ?').get(id)
270
+ return row ? projectFromRow(row) : null
271
+ }
272
+
273
+ projectByWorkspace(workspaceId: string): ForgeProject | null {
274
+ const row = this.db.query<ProjectRow, [string]>('SELECT * FROM projects WHERE workspace_id = ?').get(workspaceId)
275
+ return row ? projectFromRow(row) : null
276
+ }
277
+
278
+ projectByRoot(root: string): ForgeProject | null {
279
+ const row = this.db.query<ProjectRow, [string]>('SELECT * FROM projects WHERE root = ?').get(root)
280
+ return row ? projectFromRow(row) : null
281
+ }
282
+
283
+ insertProject(input: NewProject): ForgeProject {
284
+ const ts = now()
285
+ const id = crypto.randomUUID()
286
+ this.db
287
+ .query('INSERT INTO projects (id, root, name, profile, app_slug, git, workspace_id, created_at, last_opened_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
288
+ .run(id, input.root, input.name, input.profile, input.appSlug, input.git ? 1 : 0, input.workspaceId ?? null, ts, ts)
289
+ return this.getProject(id)!
290
+ }
291
+
292
+ /** Re-detect the parts that can change underneath us: git, app, profile. */
293
+ refreshProject(id: string, input: Pick<NewProject, 'profile' | 'appSlug' | 'git'>): ForgeProject {
294
+ this.db
295
+ .query('UPDATE projects SET profile = ?, app_slug = ?, git = ?, last_opened_at = ? WHERE id = ?')
296
+ .run(input.profile, input.appSlug, input.git ? 1 : 0, now(), id)
297
+ return this.getProject(id)!
298
+ }
299
+
300
+ deleteProject(id: string): void {
301
+ const threads = this.db.query<{ id: string }, [string]>('SELECT id FROM threads WHERE project_id = ?').all(id)
302
+ for (const t of threads) this.deleteThread(t.id)
303
+ this.db.query('DELETE FROM projects WHERE id = ?').run(id)
304
+ }
305
+
306
+ // -- threads --------------------------------------------------------------
307
+
308
+ listThreads(projectId: string): StoredThread[] {
309
+ return this.db
310
+ .query<ThreadRow, [string]>('SELECT * FROM threads WHERE project_id = ? ORDER BY updated_at DESC')
311
+ .all(projectId)
312
+ .map(threadFromRow)
313
+ }
314
+
315
+ getThread(id: string): StoredThread | null {
316
+ const row = this.db.query<ThreadRow, [string]>('SELECT * FROM threads WHERE id = ?').get(id)
317
+ return row ? threadFromRow(row) : null
318
+ }
319
+
320
+ insertThread(input: NewThread): StoredThread {
321
+ const ts = now()
322
+ const id = crypto.randomUUID()
323
+ const legacy = this.hasLegacyRuntimeMode
324
+ this.db
325
+ .query(
326
+ `INSERT INTO threads (id, project_id, host, model, mode, effort, title, status, host_session_id, active_run_id, archived, created_at, updated_at${legacy ? ', runtime_mode' : ''}) `
327
+ + `VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, ?, ?${legacy ? ', ?' : ''})`,
328
+ )
329
+ .run(
330
+ id,
331
+ input.projectId,
332
+ input.host,
333
+ input.model,
334
+ input.mode,
335
+ input.effort,
336
+ input.title,
337
+ 'idle',
338
+ ts,
339
+ ts,
340
+ ...(legacy ? [legacyRuntimeMode(input.mode)] : []),
341
+ )
342
+ return this.getThread(id)!
343
+ }
344
+
345
+ updateThread(id: string, patch: ThreadPatch): StoredThread {
346
+ const sets: string[] = []
347
+ const values: Array<string | number | null> = []
348
+ const put = (column: string, value: string | number | null) => {
349
+ sets.push(`${column} = ?`)
350
+ values.push(value)
351
+ }
352
+ if (patch.title !== undefined) put('title', patch.title)
353
+ if (patch.model !== undefined) put('model', patch.model)
354
+ if (patch.mode !== undefined) {
355
+ put('mode', patch.mode)
356
+ if (this.hasLegacyRuntimeMode) put('runtime_mode', legacyRuntimeMode(patch.mode))
357
+ }
358
+ if (patch.effort !== undefined) put('effort', patch.effort)
359
+ if (patch.status !== undefined) put('status', patch.status)
360
+ if (patch.hostSessionId !== undefined) put('host_session_id', patch.hostSessionId)
361
+ if (patch.activeRunId !== undefined) put('active_run_id', patch.activeRunId)
362
+ if (patch.archived !== undefined) put('archived', patch.archived ? 1 : 0)
363
+ put('updated_at', now())
364
+ values.push(id)
365
+ this.db.query(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...values)
366
+ const thread = this.getThread(id)
367
+ if (!thread) throw new Error(`thread ${id} vanished during update`)
368
+ return thread
369
+ }
370
+
371
+ deleteThread(id: string): void {
372
+ this.db.query('DELETE FROM messages WHERE thread_id = ?').run(id)
373
+ this.db.query('DELETE FROM runs WHERE thread_id = ?').run(id)
374
+ this.db.query('DELETE FROM threads WHERE id = ?').run(id)
375
+ }
376
+
377
+ // -- messages -------------------------------------------------------------
378
+
379
+ listMessages(threadId: string): UIMessage[] {
380
+ return this.db
381
+ .query<MessageRow, [string]>('SELECT id, seq, json FROM messages WHERE thread_id = ? ORDER BY seq ASC')
382
+ .all(threadId)
383
+ .map((row) => JSON.parse(row.json) as UIMessage)
384
+ }
385
+
386
+ appendMessage(threadId: string, message: UIMessage): number {
387
+ const last = this.db
388
+ .query<{ seq: number | null }, [string]>('SELECT MAX(seq) AS seq FROM messages WHERE thread_id = ?')
389
+ .get(threadId)
390
+ const seq = (last?.seq ?? -1) + 1
391
+ this.db
392
+ .query('INSERT INTO messages (id, thread_id, seq, role, json) VALUES (?, ?, ?, ?, ?)')
393
+ .run(message.id, threadId, seq, message.role, JSON.stringify(message))
394
+ return seq
395
+ }
396
+
397
+ // -- runs -----------------------------------------------------------------
398
+
399
+ insertRun(id: string, threadId: string): StoredRun {
400
+ this.db
401
+ .query('INSERT INTO runs (id, thread_id, status, started_at) VALUES (?, ?, ?, ?)')
402
+ .run(id, threadId, 'running', now())
403
+ return this.getRun(id)!
404
+ }
405
+
406
+ finishRun(id: string, status: Exclude<RunStatus, 'running'>, error: string | null): void {
407
+ this.db.query('UPDATE runs SET status = ?, finished_at = ?, error = ? WHERE id = ?').run(status, now(), error, id)
408
+ }
409
+
410
+ getRun(id: string): StoredRun | null {
411
+ const row = this.db.query<RunRow, [string]>('SELECT * FROM runs WHERE id = ?').get(id)
412
+ return row ? runFromRow(row) : null
413
+ }
414
+
415
+ /**
416
+ * Runs the previous daemon left open. Their journals lived in memory, so
417
+ * there is nothing to resume — the honest state is "interrupted".
418
+ */
419
+ reapOrphanRuns(): number {
420
+ const open = this.db.query<{ id: string; thread_id: string }, []>("SELECT id, thread_id FROM runs WHERE status = 'running'").all()
421
+ for (const run of open) {
422
+ this.finishRun(run.id, 'error', 'the daemon stopped while this run was live')
423
+ this.db.query("UPDATE threads SET status = 'idle', active_run_id = NULL WHERE id = ?").run(run.thread_id)
424
+ }
425
+ return open.length
426
+ }
427
+ }
@@ -0,0 +1,220 @@
1
+ import type { ForgeCreateTerminalInput, ForgeProject, ForgeTerminal, ForgeTerminalServerMessage } from '@frontera-sdk/forge-contracts'
2
+
3
+ import { basename } from 'node:path'
4
+
5
+ import { forgeEnv } from './env'
6
+ import { ForgeHttpError, badRequest } from './http'
7
+ import type { Logger } from './log'
8
+
9
+ /**
10
+ * Daemon-owned PTYs. The renderer is a byte pipe: it sends keystrokes and
11
+ * resizes, it receives output. A socket that attaches late gets the recent
12
+ * scrollback replayed so the terminal is not blank after a reload.
13
+ *
14
+ * A session shows several of these as tabs, so creation order is the order
15
+ * the tabs appear: the map is insertion-ordered and never re-sorted, which is
16
+ * what `ORDER BY created_at` would give a table-backed list.
17
+ *
18
+ * Bun's PTY support (`Bun.spawn` with `terminal`) is not available on
19
+ * Windows; that platform waits for a ConPTY path.
20
+ */
21
+
22
+ /** How much recent output a freshly attached socket receives. */
23
+ const SCROLLBACK_BYTES = 200 * 1024
24
+
25
+ const DEFAULT_COLS = 80
26
+ const DEFAULT_ROWS = 24
27
+
28
+ export interface TerminalClient {
29
+ sendBytes(bytes: Uint8Array): void
30
+ sendJson(message: ForgeTerminalServerMessage): void
31
+ }
32
+
33
+ class RingBuffer {
34
+ private chunks: Uint8Array[] = []
35
+ private bytes = 0
36
+
37
+ push(chunk: Uint8Array): void {
38
+ this.chunks.push(chunk)
39
+ this.bytes += chunk.byteLength
40
+ while (this.bytes > SCROLLBACK_BYTES && this.chunks.length > 1) {
41
+ const dropped = this.chunks.shift()!
42
+ this.bytes -= dropped.byteLength
43
+ }
44
+ }
45
+
46
+ snapshot(): Uint8Array {
47
+ const out = new Uint8Array(this.bytes)
48
+ let offset = 0
49
+ for (const chunk of this.chunks) {
50
+ out.set(chunk, offset)
51
+ offset += chunk.byteLength
52
+ }
53
+ return out
54
+ }
55
+ }
56
+
57
+ interface Live {
58
+ info: ForgeTerminal
59
+ proc: Bun.Subprocess
60
+ scrollback: RingBuffer
61
+ clients: Set<TerminalClient>
62
+ }
63
+
64
+ /** The tab label: an explicit one, else the command, else the shell's name. */
65
+ function titleFor(input: ForgeCreateTerminalInput, shell: string): string {
66
+ const explicit = input.title?.trim()
67
+ if (explicit) return explicit
68
+ const command = input.command?.trim()
69
+ if (command) return command
70
+ return basename(shell)
71
+ }
72
+
73
+ export class TerminalManager {
74
+ private readonly terminals = new Map<string, Live>()
75
+
76
+ constructor(private readonly log: Logger) {}
77
+
78
+ /** Creation order. `threadId` narrows to one session's tabs. */
79
+ list(threadId?: string | null): ForgeTerminal[] {
80
+ const all = [...this.terminals.values()].map((t) => t.info)
81
+ return threadId ? all.filter((t) => t.threadId === threadId) : all
82
+ }
83
+
84
+ get(id: string): ForgeTerminal | null {
85
+ return this.terminals.get(id)?.info ?? null
86
+ }
87
+
88
+ create(input: ForgeCreateTerminalInput, project: ForgeProject): ForgeTerminal {
89
+ if (process.platform === 'win32') {
90
+ throw new ForgeHttpError(501, 'terminals are not available on Windows yet', 'NOT_IMPLEMENTED')
91
+ }
92
+ const cols = Math.max(2, Math.floor(input.cols ?? DEFAULT_COLS))
93
+ const rows = Math.max(1, Math.floor(input.rows ?? DEFAULT_ROWS))
94
+ const shell = process.env.SHELL || '/bin/zsh'
95
+ const id = crypto.randomUUID()
96
+
97
+ let live: Live | null = null
98
+ let proc: Bun.Subprocess
99
+ try {
100
+ proc = Bun.spawn([shell, '-l'], {
101
+ cwd: project.root,
102
+ env: forgeEnv({ TERM: 'xterm-256color', COLORTERM: 'truecolor' }),
103
+ terminal: {
104
+ cols,
105
+ rows,
106
+ name: 'xterm-256color',
107
+ data: (_terminal, bytes) => {
108
+ if (!live) return
109
+ // The PTY reuses its buffer; keep our own copy.
110
+ const copy = new Uint8Array(bytes)
111
+ live.scrollback.push(copy)
112
+ for (const client of live.clients) client.sendBytes(copy)
113
+ },
114
+ },
115
+ })
116
+ } catch (err) {
117
+ throw badRequest(`could not start ${shell}: ${err instanceof Error ? err.message : String(err)}`)
118
+ }
119
+
120
+ const info: ForgeTerminal = {
121
+ id,
122
+ projectId: project.id,
123
+ threadId: input.threadId ?? null,
124
+ cwd: project.root,
125
+ shell,
126
+ title: titleFor(input, shell),
127
+ pid: proc.pid,
128
+ cols,
129
+ rows,
130
+ exited: false,
131
+ exitCode: null,
132
+ createdAt: new Date().toISOString(),
133
+ }
134
+ live = { info, proc, scrollback: new RingBuffer(), clients: new Set() }
135
+ this.terminals.set(id, live)
136
+
137
+ void proc.exited
138
+ .then((code) => this.onExit(id, Number.isInteger(code) ? code : null))
139
+ .catch(() => this.onExit(id, null))
140
+
141
+ if (input.command) {
142
+ // Let the shell finish its rc files before the command lands; a write
143
+ // during startup is read fine, but the echo interleaves with the prompt.
144
+ setTimeout(() => {
145
+ try {
146
+ proc.terminal?.write(`${input.command}\n`)
147
+ } catch {
148
+ // Exited before it could read.
149
+ }
150
+ }, 150)
151
+ }
152
+
153
+ this.log.info(`terminal ${id} opened in ${project.root} (pid ${proc.pid})`)
154
+ return info
155
+ }
156
+
157
+ /** Rename a tab. Null when the terminal is gone. */
158
+ rename(id: string, title: string): ForgeTerminal | null {
159
+ const live = this.terminals.get(id)
160
+ if (!live) return null
161
+ live.info.title = title
162
+ return live.info
163
+ }
164
+
165
+ attach(id: string, client: TerminalClient): boolean {
166
+ const live = this.terminals.get(id)
167
+ if (!live) return false
168
+ live.clients.add(client)
169
+ const replay = live.scrollback.snapshot()
170
+ if (replay.byteLength > 0) client.sendBytes(replay)
171
+ if (live.info.exited) client.sendJson({ type: 'exit', exitCode: live.info.exitCode })
172
+ return true
173
+ }
174
+
175
+ detach(id: string, client: TerminalClient): void {
176
+ this.terminals.get(id)?.clients.delete(client)
177
+ }
178
+
179
+ write(id: string, data: string | Uint8Array): void {
180
+ const live = this.terminals.get(id)
181
+ if (!live || live.info.exited) return
182
+ live.proc.terminal?.write(data)
183
+ }
184
+
185
+ resize(id: string, cols: number, rows: number): void {
186
+ const live = this.terminals.get(id)
187
+ if (!live || live.info.exited) return
188
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 2 || rows < 1) return
189
+ live.info.cols = Math.floor(cols)
190
+ live.info.rows = Math.floor(rows)
191
+ live.proc.terminal?.resize(live.info.cols, live.info.rows)
192
+ }
193
+
194
+ kill(id: string): boolean {
195
+ const live = this.terminals.get(id)
196
+ if (!live) return false
197
+ if (!live.info.exited) {
198
+ try {
199
+ live.proc.kill('SIGHUP')
200
+ } catch {
201
+ // Already gone.
202
+ }
203
+ }
204
+ this.terminals.delete(id)
205
+ return true
206
+ }
207
+
208
+ killAll(): void {
209
+ for (const id of [...this.terminals.keys()]) this.kill(id)
210
+ }
211
+
212
+ private onExit(id: string, exitCode: number | null): void {
213
+ const live = this.terminals.get(id)
214
+ if (!live || live.info.exited) return
215
+ live.info.exited = true
216
+ live.info.exitCode = exitCode
217
+ for (const client of live.clients) client.sendJson({ type: 'exit', exitCode })
218
+ this.log.info(`terminal ${id} exited (${exitCode ?? 'signal'})`)
219
+ }
220
+ }