@shawnstack/quickforge 1.1.0 → 1.2.1

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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/bin/quickforge.mjs +72 -7
  3. package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-D93MV5Cx.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/index-DoraECXN.js +3187 -0
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-_z7sua8J.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +14 -13
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +322 -32
  27. package/server/project-config.mjs +80 -31
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +46 -10
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +58 -10
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +247 -6
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +30 -0
  52. package/server/utils/response.mjs +8 -1
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-4mZt9XEq.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-Bq6VHkyY.js +0 -3048
  65. package/dist/assets/index-D7uXa1RT.css +0 -3
  66. package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
  67. package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
  68. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  69. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  70. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  71. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  72. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,323 @@
1
+ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
+ import {
3
+ createAgent,
4
+ runPrompt,
5
+ abortRun,
6
+ steerAgent,
7
+ followUpAgent,
8
+ getSessionState,
9
+ getSessionEventBus,
10
+ tryAcquireSse,
11
+ releaseSse,
12
+ isSseConnected,
13
+ destroyAgent,
14
+ restoreAgent,
15
+ touchSession,
16
+ listSessions,
17
+ updateSessionModel,
18
+ updateSessionThinkingLevel,
19
+ agentEvents,
20
+ } from '../agent-manager.mjs'
21
+
22
+ export async function handleAgentApi(req, res, url) {
23
+ const pathname = url.pathname
24
+ const parts = pathname.split('/').filter(Boolean)
25
+
26
+ // GET /api/agents — list active sessions
27
+ if (req.method === 'GET' && pathname === '/api/agents') {
28
+ sendJson(res, 200, { sessions: listSessions() })
29
+ return
30
+ }
31
+
32
+ // GET /api/agents/events — global SSE event stream for all sessions
33
+ if (req.method === 'GET' && pathname === '/api/agents/events') {
34
+ handleGlobalStream(req, res)
35
+ return
36
+ }
37
+
38
+ // All other routes need a session ID: /api/agents/:sessionId/...
39
+ if (parts.length < 3 || parts[1] !== 'agents') {
40
+ const error = new Error('Not found')
41
+ error.statusCode = 404
42
+ throw error
43
+ }
44
+
45
+ const sessionId = decodeSegment(parts[2])
46
+ if (!sessionId) {
47
+ const error = new Error('Missing session ID')
48
+ error.statusCode = 400
49
+ throw error
50
+ }
51
+
52
+ const subPath = parts.slice(3).join('/')
53
+
54
+ // GET /api/agents/:sessionId/stream — SSE event stream
55
+ // HEAD /api/agents/:sessionId/stream — check if SSE is available (200) or taken (409)
56
+ if (subPath === 'stream') {
57
+ if (req.method === 'GET') {
58
+ await handleStream(req, res, sessionId)
59
+ return
60
+ }
61
+ if (req.method === 'HEAD') {
62
+ await handleStreamHead(req, res, sessionId)
63
+ return
64
+ }
65
+ }
66
+
67
+ // POST /api/agents/:sessionId/prompt — send user message
68
+ if (req.method === 'POST' && subPath === 'prompt') {
69
+ const body = await readJsonBody(req)
70
+ const message = body?.message
71
+ if (!message) {
72
+ const error = new Error('Missing message in request body')
73
+ error.statusCode = 400
74
+ throw error
75
+ }
76
+ const result = await runPrompt(sessionId, message)
77
+ sendJson(res, 200, result)
78
+ return
79
+ }
80
+
81
+ // POST /api/agents/:sessionId/abort — abort current run
82
+ if (req.method === 'POST' && subPath === 'abort') {
83
+ const result = abortRun(sessionId)
84
+ sendJson(res, 200, result)
85
+ return
86
+ }
87
+
88
+ // GET /api/agents/:sessionId/state — get session state
89
+ if (req.method === 'GET' && subPath === 'state') {
90
+ const state = getSessionState(sessionId)
91
+ if (!state) {
92
+ const error = new Error('Session not found')
93
+ error.statusCode = 404
94
+ throw error
95
+ }
96
+ sendJson(res, 200, state)
97
+ return
98
+ }
99
+
100
+ // POST /api/agents/:sessionId — create/ensure agent
101
+ if (req.method === 'POST' && parts.length === 3) {
102
+ const body = await readJsonBody(req)
103
+ const session = await createAgent(sessionId, body)
104
+ sendJson(res, 200, {
105
+ sessionId: session.sessionId,
106
+ status: session.status,
107
+ scope: session.scope,
108
+ title: session.title,
109
+ })
110
+ return
111
+ }
112
+
113
+ // DELETE /api/agents/:sessionId — destroy agent
114
+ if (req.method === 'DELETE' && parts.length === 3) {
115
+ await destroyAgent(sessionId)
116
+ sendJson(res, 200, { ok: true })
117
+ return
118
+ }
119
+
120
+ // POST /api/agents/:sessionId/model — update session model
121
+ if (req.method === 'POST' && subPath === 'model') {
122
+ const body = await readJsonBody(req)
123
+ const model = body?.model
124
+ if (!model) {
125
+ const error = new Error('Missing model in request body')
126
+ error.statusCode = 400
127
+ throw error
128
+ }
129
+ const result = updateSessionModel(sessionId, model)
130
+ sendJson(res, 200, result)
131
+ return
132
+ }
133
+
134
+ // POST /api/agents/:sessionId/thinking-level — update session thinking level
135
+ if (req.method === 'POST' && subPath === 'thinking-level') {
136
+ const body = await readJsonBody(req)
137
+ const thinkingLevel = body?.thinkingLevel
138
+ if (!thinkingLevel) {
139
+ const error = new Error('Missing thinkingLevel in request body')
140
+ error.statusCode = 400
141
+ throw error
142
+ }
143
+ const result = updateSessionThinkingLevel(sessionId, thinkingLevel)
144
+ sendJson(res, 200, result)
145
+ return
146
+ }
147
+
148
+ // POST /api/agents/:sessionId/steer — queue steering message
149
+ if (req.method === 'POST' && subPath === 'steer') {
150
+ const body = await readJsonBody(req)
151
+ const message = body?.message
152
+ if (!message) {
153
+ const error = new Error('Missing message in request body')
154
+ error.statusCode = 400
155
+ throw error
156
+ }
157
+ const result = steerAgent(sessionId, message)
158
+ sendJson(res, 200, result)
159
+ return
160
+ }
161
+
162
+ // POST /api/agents/:sessionId/follow-up — queue follow-up message
163
+ if (req.method === 'POST' && subPath === 'follow-up') {
164
+ const body = await readJsonBody(req)
165
+ const message = body?.message
166
+ if (!message) {
167
+ const error = new Error('Missing message in request body')
168
+ error.statusCode = 400
169
+ throw error
170
+ }
171
+ const result = followUpAgent(sessionId, message)
172
+ sendJson(res, 200, result)
173
+ return
174
+ }
175
+
176
+ const error = new Error('Not found')
177
+ error.statusCode = 404
178
+ throw error
179
+ }
180
+
181
+ /**
182
+ * HEAD request to check whether the SSE stream for a session is available.
183
+ * Returns 200 if available, 409 if already connected, 404 if session not found.
184
+ */
185
+ async function handleStreamHead(req, res, sessionId) {
186
+ // Ensure session exists (restore from storage if needed)
187
+ if (!getSessionEventBus(sessionId)) {
188
+ const restored = await restoreAgent(sessionId)
189
+ if (!restored) {
190
+ sendJson(res, 404, { error: 'Session not found' })
191
+ return
192
+ }
193
+ }
194
+
195
+ if (isSseConnected(sessionId)) {
196
+ sendJson(res, 409, { error: 'Session is already active in another tab' })
197
+ return
198
+ }
199
+
200
+ res.writeHead(200, { 'content-length': '0' })
201
+ res.end()
202
+ }
203
+
204
+ function handleGlobalStream(req, res) {
205
+ res.writeHead(200, {
206
+ 'content-type': 'text/event-stream',
207
+ 'cache-control': 'no-cache, no-transform',
208
+ 'connection': 'keep-alive',
209
+ 'x-accel-buffering': 'no',
210
+ })
211
+
212
+ const keepAlive = setInterval(() => {
213
+ try {
214
+ res.write(': ping\n\n')
215
+ } catch {
216
+ cleanup()
217
+ }
218
+ }, 15000)
219
+
220
+ const onAgentEvent = (event) => {
221
+ try {
222
+ writeSseEvent(res, event.type || 'agent_event', event)
223
+ } catch {
224
+ cleanup()
225
+ }
226
+ }
227
+
228
+ const cleanup = () => {
229
+ clearInterval(keepAlive)
230
+ agentEvents.removeListener('agent_event', onAgentEvent)
231
+ if (!res.writableEnded) {
232
+ res.end()
233
+ }
234
+ }
235
+
236
+ agentEvents.on('agent_event', onAgentEvent)
237
+
238
+ req.on('close', cleanup)
239
+ req.on('error', cleanup)
240
+ res.on('error', cleanup)
241
+ }
242
+
243
+ async function handleStream(req, res, sessionId) {
244
+ // Restore from storage if not already in memory
245
+ let eventBus = getSessionEventBus(sessionId)
246
+ if (!eventBus) {
247
+ const restored = await restoreAgent(sessionId)
248
+ if (restored) {
249
+ eventBus = restored.eventBus
250
+ } else {
251
+ sendJson(res, 404, { error: 'Session not found' })
252
+ return
253
+ }
254
+ }
255
+
256
+ // Only one SSE connection per session — reject with 409 so the client can fall back
257
+ if (!tryAcquireSse(sessionId)) {
258
+ sendJson(res, 409, { error: 'Session is already active in another tab' })
259
+ return
260
+ }
261
+
262
+ // Reset idle timer — active SSE connection keeps session alive
263
+ touchSession(sessionId)
264
+
265
+ // Set SSE headers
266
+ res.writeHead(200, {
267
+ 'content-type': 'text/event-stream',
268
+ 'cache-control': 'no-cache, no-transform',
269
+ 'connection': 'keep-alive',
270
+ 'x-accel-buffering': 'no',
271
+ })
272
+
273
+ // Send initial state
274
+ const state = getSessionState(sessionId)
275
+ if (state) {
276
+ writeSseEvent(res, 'state', state)
277
+ }
278
+
279
+ // Keep-alive ping every 15 seconds — also resets idle timer
280
+ const keepAlive = setInterval(() => {
281
+ try {
282
+ res.write(': ping\n\n')
283
+ touchSession(sessionId)
284
+ } catch {
285
+ cleanup()
286
+ }
287
+ }, 15000)
288
+
289
+ // Handle agent events
290
+ const onAgentEvent = (event) => {
291
+ try {
292
+ writeSseEvent(res, event.type, event)
293
+ } catch {
294
+ cleanup()
295
+ }
296
+ }
297
+
298
+ const cleanup = () => {
299
+ clearInterval(keepAlive)
300
+ eventBus.removeListener('agent_event', onAgentEvent)
301
+ releaseSse(sessionId)
302
+ if (!res.writableEnded) {
303
+ res.end()
304
+ }
305
+ }
306
+
307
+ eventBus.on('agent_event', onAgentEvent)
308
+
309
+ req.on('close', cleanup)
310
+ req.on('error', cleanup)
311
+ res.on('error', cleanup)
312
+ }
313
+
314
+ function writeSseEvent(res, event, data) {
315
+ const payload = typeof data === 'string' ? data : JSON.stringify(data)
316
+ // Split multi-line payloads
317
+ const lines = payload.split('\n')
318
+ res.write(`event: ${event}\n`)
319
+ for (const line of lines) {
320
+ res.write(`data: ${line}\n`)
321
+ }
322
+ res.write('\n')
323
+ }
@@ -0,0 +1,250 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { sendJson, readJsonBody } from '../utils/response.mjs'
4
+ import {
5
+ ensureStorage,
6
+ readStore,
7
+ writeStore,
8
+ readProjectConfigData,
9
+ writeProjectConfigData,
10
+ storageDir,
11
+ } from '../storage.mjs'
12
+ import { initializeActiveProject } from '../project-config.mjs'
13
+ import { setActiveWorkspaceRootForFilesystem } from './filesystem.mjs'
14
+ import { getWorkspaceRoot } from '../utils/workspace.mjs'
15
+
16
+ const BACKUP_VERSION = 1
17
+ const BACKUP_APP = 'quickforge'
18
+ const backupScopes = new Set(['all', 'config', 'sessions'])
19
+
20
+ function normalizeScope(value) {
21
+ const scope = String(value || 'all')
22
+ return backupScopes.has(scope) ? scope : 'all'
23
+ }
24
+
25
+ function backupTimestamp(date = new Date()) {
26
+ return date.toISOString().replace(/[:.]/g, '-')
27
+ }
28
+
29
+ function section(data, key, legacyKey) {
30
+ if (!data || typeof data !== 'object') return undefined
31
+ if (Object.prototype.hasOwnProperty.call(data, key)) return data[key]
32
+ if (legacyKey && Object.prototype.hasOwnProperty.call(data, legacyKey)) return data[legacyKey]
33
+ return undefined
34
+ }
35
+
36
+ function assertObjectSection(value, name) {
37
+ if (value === undefined) return undefined
38
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
39
+ const error = new Error(`Invalid backup section: ${name}`)
40
+ error.statusCode = 400
41
+ throw error
42
+ }
43
+ return value
44
+ }
45
+
46
+ function assertProjectConfig(value) {
47
+ const projectConfig = assertObjectSection(value, 'projects')
48
+ if (projectConfig === undefined) return undefined
49
+ if (!Array.isArray(projectConfig.projects)) {
50
+ const error = new Error('Invalid backup section: projects.projects must be an array')
51
+ error.statusCode = 400
52
+ throw error
53
+ }
54
+ return {
55
+ activeProjectId: typeof projectConfig.activeProjectId === 'string' ? projectConfig.activeProjectId : null,
56
+ projects: projectConfig.projects,
57
+ }
58
+ }
59
+
60
+ function filterSessionsByMetadata(sessions, metadata) {
61
+ if (!sessions || !metadata) return sessions
62
+ const metadataIds = new Set(Object.keys(metadata))
63
+ return Object.fromEntries(Object.entries(sessions).filter(([sessionId]) => metadataIds.has(sessionId)))
64
+ }
65
+
66
+ function normalizeSessionMetadata(sessions, metadata) {
67
+ if (!sessions) return metadata
68
+ const sessionsObject = assertObjectSection(sessions, 'sessions')
69
+ const metadataObject = metadata === undefined ? {} : assertObjectSection(metadata, 'sessionsMetadata')
70
+ const nextMetadata = {}
71
+ const now = new Date().toISOString()
72
+
73
+ for (const [sessionId, session] of Object.entries(sessionsObject)) {
74
+ if (!session || typeof session !== 'object' || Array.isArray(session)) continue
75
+ const existing = metadataObject?.[sessionId]
76
+ nextMetadata[sessionId] = existing && typeof existing === 'object' && !Array.isArray(existing)
77
+ ? existing
78
+ : {
79
+ id: sessionId,
80
+ title: typeof session.title === 'string' ? session.title : 'New chat',
81
+ createdAt: typeof session.createdAt === 'string' ? session.createdAt : now,
82
+ lastModified: typeof session.lastModified === 'string' ? session.lastModified : now,
83
+ messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
84
+ thinkingLevel: typeof session.thinkingLevel === 'string' ? session.thinkingLevel : 'off',
85
+ preview: '',
86
+ scope: session.scope === 'project' ? 'project' : 'global',
87
+ projectId: session.scope === 'project' && session.projectId ? String(session.projectId) : undefined,
88
+ taskStatus: session.taskStatus || 'idle',
89
+ taskStartedAt: session.taskStartedAt ?? null,
90
+ taskFinishedAt: session.taskFinishedAt ?? null,
91
+ }
92
+ }
93
+
94
+ return nextMetadata
95
+ }
96
+
97
+ async function buildBackup(scope = 'all') {
98
+ const normalizedScope = normalizeScope(scope)
99
+ const includeConfig = normalizedScope === 'all' || normalizedScope === 'config'
100
+ const includeSessions = normalizedScope === 'all' || normalizedScope === 'sessions'
101
+ const data = {}
102
+
103
+ if (includeConfig) {
104
+ const [settings, providerKeys, customProviders, projects, scheduledTasks] = await Promise.all([
105
+ readStore('settings'),
106
+ readStore('provider-keys'),
107
+ readStore('custom-providers'),
108
+ readProjectConfigData(),
109
+ readStore('scheduled-tasks'),
110
+ ])
111
+ Object.assign(data, {
112
+ settings,
113
+ providerKeys,
114
+ customProviders,
115
+ projects,
116
+ scheduledTasks,
117
+ })
118
+ }
119
+
120
+ if (includeSessions) {
121
+ const [sessions, sessionsMetadata] = await Promise.all([
122
+ readStore('sessions'),
123
+ readStore('sessions-metadata'),
124
+ ])
125
+ Object.assign(data, {
126
+ sessions,
127
+ sessionsMetadata,
128
+ })
129
+ }
130
+
131
+ return {
132
+ app: BACKUP_APP,
133
+ version: BACKUP_VERSION,
134
+ exportedAt: new Date().toISOString(),
135
+ scope: normalizedScope,
136
+ data,
137
+ }
138
+ }
139
+
140
+ function normalizeBackupPayload(payload) {
141
+ const backup = payload?.backup && typeof payload.backup === 'object' ? payload.backup : payload
142
+ if (!backup || typeof backup !== 'object' || Array.isArray(backup)) {
143
+ const error = new Error('Invalid backup file')
144
+ error.statusCode = 400
145
+ throw error
146
+ }
147
+
148
+ const data = backup.data && typeof backup.data === 'object' && !Array.isArray(backup.data)
149
+ ? backup.data
150
+ : backup
151
+
152
+ const normalized = {
153
+ settings: section(data, 'settings'),
154
+ providerKeys: section(data, 'providerKeys', 'provider-keys'),
155
+ customProviders: section(data, 'customProviders', 'custom-providers'),
156
+ projects: section(data, 'projects'),
157
+ scheduledTasks: section(data, 'scheduledTasks', 'scheduled-tasks'),
158
+ sessions: section(data, 'sessions'),
159
+ sessionsMetadata: section(data, 'sessionsMetadata', 'sessions-metadata'),
160
+ }
161
+
162
+ if (Object.values(normalized).every((value) => value === undefined)) {
163
+ const error = new Error('Backup does not contain any restorable sections')
164
+ error.statusCode = 400
165
+ throw error
166
+ }
167
+
168
+ return normalized
169
+ }
170
+
171
+ async function writeSafetyBackup() {
172
+ const backup = await buildBackup('all')
173
+ const dir = path.join(storageDir, 'backups')
174
+ await fs.mkdir(dir, { recursive: true })
175
+ const file = path.join(dir, `quickforge-before-restore-${backupTimestamp()}.json`)
176
+ await fs.writeFile(file, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
177
+ return file
178
+ }
179
+
180
+ async function restoreBackup(payload) {
181
+ const backup = normalizeBackupPayload(payload)
182
+ const summary = {}
183
+
184
+ const settings = assertObjectSection(backup.settings, 'settings')
185
+ if (settings !== undefined) {
186
+ await writeStore('settings', settings)
187
+ summary.settings = Object.keys(settings).length
188
+ }
189
+
190
+ const providerKeys = assertObjectSection(backup.providerKeys, 'providerKeys')
191
+ if (providerKeys !== undefined) {
192
+ await writeStore('provider-keys', providerKeys)
193
+ summary.providerKeys = Object.keys(providerKeys).length
194
+ }
195
+
196
+ const customProviders = assertObjectSection(backup.customProviders, 'customProviders')
197
+ if (customProviders !== undefined) {
198
+ await writeStore('custom-providers', customProviders)
199
+ summary.customProviders = Object.keys(customProviders).length
200
+ }
201
+
202
+ const projects = assertProjectConfig(backup.projects)
203
+ if (projects !== undefined) {
204
+ await writeProjectConfigData(projects)
205
+ await initializeActiveProject()
206
+ setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
207
+ summary.projects = projects.projects.length
208
+ }
209
+
210
+ const scheduledTasks = assertObjectSection(backup.scheduledTasks, 'scheduledTasks')
211
+ if (scheduledTasks !== undefined) {
212
+ await writeStore('scheduled-tasks', scheduledTasks)
213
+ summary.scheduledTasks = Object.keys(scheduledTasks).length
214
+ }
215
+
216
+ const sessions = assertObjectSection(backup.sessions, 'sessions')
217
+ const sessionsMetadata = normalizeSessionMetadata(sessions, backup.sessionsMetadata)
218
+ if (sessions !== undefined) {
219
+ await writeStore('sessions', filterSessionsByMetadata(sessions, sessionsMetadata))
220
+ summary.sessions = Object.keys(sessions).length
221
+ }
222
+
223
+ if (sessionsMetadata !== undefined) {
224
+ await writeStore('sessions-metadata', sessionsMetadata)
225
+ summary.sessionsMetadata = Object.keys(sessionsMetadata).length
226
+ }
227
+
228
+ return summary
229
+ }
230
+
231
+ export async function handleBackupApi(req, res, url) {
232
+ if (req.method === 'GET' && url.pathname === '/api/backup/export') {
233
+ await ensureStorage()
234
+ sendJson(res, 200, await buildBackup(url.searchParams.get('scope')))
235
+ return
236
+ }
237
+
238
+ if (req.method === 'POST' && url.pathname === '/api/backup/import') {
239
+ await ensureStorage()
240
+ const body = await readJsonBody(req)
241
+ const safetyBackupPath = await writeSafetyBackup()
242
+ const summary = await restoreBackup(body)
243
+ sendJson(res, 200, { ok: true, safetyBackupPath, summary })
244
+ return
245
+ }
246
+
247
+ const error = new Error('Not found')
248
+ error.statusCode = 404
249
+ throw error
250
+ }
@@ -1,7 +1,6 @@
1
- import path from 'node:path'
2
1
  import { sendJson } from '../utils/response.mjs'
3
- import { readInstructionsFile, projectContextFromId } from '../project-config.mjs'
4
- import { dataDir } from '../storage.mjs'
2
+ import { buildInstructionsPayload } from '../project-config.mjs'
3
+ import { BASE_SYSTEM_PROMPT, composeSystemPrompt } from '../system-prompt.mjs'
5
4
 
6
5
  export async function handleInstructionsApi(req, res, url) {
7
6
  if (req.method !== 'GET') {
@@ -11,21 +10,11 @@ export async function handleInstructionsApi(req, res, url) {
11
10
  }
12
11
 
13
12
  const projectId = url.searchParams.get('projectId')
14
- let projectInstructions = null
15
-
16
- if (projectId) {
17
- try {
18
- const { workspaceRoot } = await projectContextFromId(projectId)
19
- projectInstructions = await readInstructionsFile(path.join(workspaceRoot, 'AGENTS.md'))
20
- } catch {
21
- // project not found or inaccessible — leave projectInstructions null
22
- }
23
- }
24
-
25
- const globalInstructions = await readInstructionsFile(path.join(dataDir, 'AGENTS.md'))
13
+ const instructions = await buildInstructionsPayload(projectId)
26
14
 
27
15
  sendJson(res, 200, {
28
- global: globalInstructions,
29
- project: projectInstructions,
16
+ base: BASE_SYSTEM_PROMPT,
17
+ systemPrompt: composeSystemPrompt(instructions),
18
+ ...instructions,
30
19
  })
31
20
  }
@@ -1,7 +1,9 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
- import { getActiveProject, setActiveProjectPath, readProjectConfig, writeProjectConfig } from '../project-config.mjs'
2
+ import { getActiveProject, setActiveProjectPath, readProjectConfig } from '../project-config.mjs'
3
+ import { listProjectCommands } from '../custom-commands.mjs'
4
+ import { atomicProjectConfigUpdate } from '../storage.mjs'
3
5
  import { getWorkspaceRoot, setWorkspaceRoot } from '../utils/workspace.mjs'
4
- import { selectDirectoryDialog } from '../utils/platform.mjs'
6
+ import { selectDirectoryDialog, openPathInFileManager } from '../utils/platform.mjs'
5
7
  import path from 'node:path'
6
8
 
7
9
  export async function handleProjectApi(req, res, url) {
@@ -12,6 +14,25 @@ export async function handleProjectApi(req, res, url) {
12
14
  return
13
15
  }
14
16
 
17
+ if (req.method === 'GET' && url.pathname === '/api/project/commands') {
18
+ const projectId = url.searchParams.get('projectId')
19
+ const project = projectId
20
+ ? config.projects.find((item) => item.id === projectId)
21
+ : getActiveProject(config)
22
+ const commands = project?.path ? await listProjectCommands(project.path) : []
23
+ sendJson(res, 200, {
24
+ commands: commands.map((command) => ({
25
+ name: command.name,
26
+ description: command.description,
27
+ argumentHint: command.argumentHint,
28
+ allowEdit: command.allowEdit,
29
+ allowCommands: command.allowCommands,
30
+ relativePath: command.relativePath,
31
+ })),
32
+ })
33
+ return
34
+ }
35
+
15
36
  if (req.method === 'POST' && url.pathname === '/api/project/select-directory') {
16
37
  console.log('[project] Opening directory picker dialog...')
17
38
  const selectedPath = await selectDirectoryDialog()
@@ -45,22 +66,37 @@ export async function handleProjectApi(req, res, url) {
45
66
  return
46
67
  }
47
68
 
48
- if (req.method === 'DELETE' && url.pathname.startsWith('/api/project/')) {
69
+ if (req.method === 'POST' && url.pathname.startsWith('/api/project/') && url.pathname.endsWith('/open-in-explorer')) {
49
70
  const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
50
- const nextProjects = config.projects.filter((project) => project.id !== id)
51
- if (nextProjects.length === config.projects.length) {
71
+ const selected = config.projects.find((project) => project.id === id)
72
+ if (!selected) {
52
73
  const error = new Error('Unknown project')
53
74
  error.statusCode = 404
54
75
  throw error
55
76
  }
56
- config.projects = nextProjects
57
- if (config.activeProjectId === id) config.activeProjectId = config.projects[0]?.id ?? null
58
- await writeProjectConfig(config)
59
- const active = getActiveProject(config)
77
+ await openPathInFileManager(selected.path)
78
+ sendJson(res, 200, { ok: true })
79
+ return
80
+ }
81
+
82
+ if (req.method === 'DELETE' && url.pathname.startsWith('/api/project/')) {
83
+ const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
84
+ const updated = await atomicProjectConfigUpdate((cfg) => {
85
+ const nextProjects = cfg.projects.filter((project) => project.id !== id)
86
+ if (nextProjects.length === cfg.projects.length) {
87
+ const error = new Error('Unknown project')
88
+ error.statusCode = 404
89
+ throw error
90
+ }
91
+ cfg.projects = nextProjects
92
+ if (cfg.activeProjectId === id) cfg.activeProjectId = cfg.projects[0]?.id ?? null
93
+ return cfg
94
+ })
95
+ const active = getActiveProject(updated)
60
96
  if (active?.path) {
61
97
  setWorkspaceRoot(path.resolve(active.path))
62
98
  }
63
- sendJson(res, 200, { project: active ?? null, projects: config.projects, workspaceRoot: getWorkspaceRoot() })
99
+ sendJson(res, 200, { project: active ?? null, projects: updated.projects, workspaceRoot: getWorkspaceRoot() })
64
100
  return
65
101
  }
66
102