@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,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
|
+
}
|