@shawnstack/quickforge 1.0.0 → 1.2.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.
Files changed (71) hide show
  1. package/README.md +22 -16
  2. package/bin/quickforge.mjs +83 -8
  3. package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-y0o2eCZV.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-CK_34smc.js → index-DoraECXN.js} +801 -662
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-D2RkRvTj.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 +2 -1
  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 +326 -34
  27. package/server/project-config.mjs +85 -55
  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 +49 -19
  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 +66 -12
  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 +578 -133
  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 +31 -1
  52. package/server/utils/response.mjs +9 -2
  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-DSmrqQ60.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-BQJ8qi1U.css +0 -3
  65. package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
  66. package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
  67. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  68. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  69. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  70. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  71. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
package/server/index.mjs CHANGED
@@ -2,47 +2,183 @@
2
2
  import { createServer } from 'node:http'
3
3
  import { spawn } from 'node:child_process'
4
4
  import path from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
5
6
  import { fileURLToPath } from 'node:url'
6
7
  import { sendJson, sendError } from './utils/response.mjs'
7
8
  import { openBrowser } from './utils/platform.mjs'
8
- import { migrateLegacyDataDirs, ensureStorage, dataDir, storageDir } from './storage.mjs'
9
+ import { ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from './storage.mjs'
9
10
  import { setDefaultWorkspaceRoot, initializeActiveProject, readProjectConfig, getActiveProject } from './project-config.mjs'
10
11
  import { getWorkspaceRoot } from './utils/workspace.mjs'
11
12
  import { handleStorageApi } from './routes/storage.mjs'
12
13
  import { handleProjectApi } from './routes/project.mjs'
13
- import { handleFilesystemApi } from './routes/filesystem.mjs'
14
- import { handleToolApi } from './routes/tools.mjs'
14
+ import { handleFilesystemApi, setActiveWorkspaceRootForFilesystem } from './routes/filesystem.mjs'
15
+ import { handleToolApi, handleGetTools } from './routes/tools.mjs'
15
16
  import { handleInstructionsApi } from './routes/instructions.mjs'
17
+ import { handleSkillsApi } from './routes/skills.mjs'
18
+ import { handleAgentApi } from './routes/agent.mjs'
19
+ import { handleScheduledTasksApi, startScheduledTaskRunner, stopScheduledTaskRunner } from './routes/scheduled-tasks.mjs'
20
+ import { handleBackupApi } from './routes/backup.mjs'
21
+ import { handleSystemApi } from './routes/system.mjs'
22
+ import { handleSharesApi } from './routes/shares.mjs'
23
+ import { handleSharedConversationApi } from './routes/shared-conversation.mjs'
16
24
  import { serveStatic } from './routes/static.mjs'
17
- import { setActiveWorkspaceRootForFilesystem } from './routes/filesystem.mjs'
25
+ import { logger } from './utils/logger.mjs'
26
+ import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
27
+ import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agent-manager.mjs'
18
28
 
19
29
  const __filename = fileURLToPath(import.meta.url)
20
30
  const __dirname = path.dirname(__filename)
21
31
  const projectRoot = path.resolve(__dirname, '..')
32
+ const serverScript = path.join(__dirname, 'index.mjs')
33
+ const restartSupervisorScript = path.join(__dirname, 'restart-supervisor.mjs')
34
+ const bootId = randomUUID()
35
+ const startedAt = new Date().toISOString()
22
36
 
23
37
  const isDev = process.argv.includes('--dev')
24
- const host = process.env.QUICKFORGE_HOST || process.env.FASTCODE_HOST || '127.0.0.1'
25
- const port = Number(process.env.QUICKFORGE_PORT || process.env.FASTCODE_PORT || (isDev ? 32176 : 5176))
26
- const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || process.env.FASTCODE_VITE_PORT || 5176)
38
+ const shareLanEnabled = process.env.QUICKFORGE_SHARE_LAN !== '0'
39
+ const host = process.env.QUICKFORGE_HOST || '0.0.0.0'
40
+ if (!['127.0.0.1', 'localhost'].includes(host) && process.env.QUICKFORGE_ALLOW_REMOTE !== '1' && !shareLanEnabled) {
41
+ throw new Error('Remote binding is disabled by default. Set QUICKFORGE_ALLOW_REMOTE=1 or keep QUICKFORGE_SHARE_LAN enabled to allow it.')
42
+ }
43
+ const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
44
+ const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
45
+ let restartInProgress = false
46
+
47
+ setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || projectRoot)
48
+
49
+ function getRestartSupport() {
50
+ return { supported: true, reason: null }
51
+ }
52
+
53
+ async function getSystemStatus() {
54
+ const config = await readProjectConfig()
55
+ const restartSupport = getRestartSupport()
56
+ return {
57
+ ok: true,
58
+ mode: isDev ? 'development' : 'production',
59
+ pid: process.pid,
60
+ bootId,
61
+ startedAt,
62
+ restartSupported: restartSupport.supported,
63
+ restartUnsupportedReason: restartSupport.reason,
64
+ dataDir,
65
+ configDir,
66
+ storageDir,
67
+ cacheDir,
68
+ logsDir,
69
+ workspaceRoot: getWorkspaceRoot(),
70
+ host,
71
+ port,
72
+ shareLanEnabled,
73
+ lanUrls: getLanUrls(port),
74
+ project: getActiveProject(config),
75
+ }
76
+ }
77
+
78
+ function spawnRestartSupervisor() {
79
+ return new Promise((resolve, reject) => {
80
+ const child = spawn(process.execPath, [
81
+ restartSupervisorScript,
82
+ String(process.pid),
83
+ serverScript,
84
+ projectRoot,
85
+ ...process.argv.slice(2),
86
+ ], {
87
+ cwd: projectRoot,
88
+ detached: true,
89
+ stdio: 'ignore',
90
+ windowsHide: true,
91
+ shell: false,
92
+ env: {
93
+ ...process.env,
94
+ QUICKFORGE_NO_OPEN: '1',
95
+ },
96
+ })
97
+
98
+ child.once('error', reject)
99
+ child.once('spawn', () => {
100
+ child.unref()
101
+ resolve(child.pid)
102
+ })
103
+ })
104
+ }
105
+
106
+ function closeHttpServer() {
107
+ return new Promise((resolve) => {
108
+ const forceTimer = setTimeout(() => {
109
+ server.closeAllConnections?.()
110
+ resolve()
111
+ }, 1500)
112
+
113
+ server.close(() => {
114
+ clearTimeout(forceTimer)
115
+ resolve()
116
+ })
117
+ server.closeIdleConnections?.()
118
+ })
119
+ }
120
+
121
+ async function performRestart() {
122
+ logger.info('Restart requested from settings UI.')
123
+ const supervisorPid = await spawnRestartSupervisor()
124
+ logger.info(`Restart supervisor started (PID ${supervisorPid}).`)
125
+
126
+ stopScheduledTaskRunner()
127
+ stopVite()
128
+ await shutdownAgentManager()
129
+ await closeHttpServer()
130
+ process.exit(0)
131
+ }
132
+
133
+ async function requestRestart() {
134
+ if (restartInProgress) {
135
+ const error = new Error('Restart already in progress')
136
+ error.statusCode = 423
137
+ throw error
138
+ }
139
+
140
+ const restartSupport = getRestartSupport()
141
+ if (!restartSupport.supported) {
142
+ const error = new Error(restartSupport.reason || 'Restart is not supported')
143
+ error.statusCode = 409
144
+ throw error
145
+ }
27
146
 
28
- setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || process.env.FASTCODE_WORKSPACE_DIR || projectRoot)
147
+ restartInProgress = true
148
+ setTimeout(() => {
149
+ void performRestart().catch((error) => {
150
+ logger.error('Failed to restart QuickForge:', error)
151
+ restartInProgress = false
152
+ })
153
+ }, 100)
154
+
155
+ return { ok: true, restarting: true, bootId }
156
+ }
29
157
 
30
158
  // --- Route dispatching ---
31
159
  async function handleApi(req, res, url) {
32
160
  const pathname = url.pathname
33
161
  const parts = pathname.split('/').filter(Boolean)
34
162
 
163
+ // Conversation share routes (management + public LAN access)
164
+ if (pathname === '/api/shares' || pathname.startsWith('/api/shares/')) {
165
+ await handleSharesApi(req, res, url, { port })
166
+ return
167
+ }
168
+
169
+ if (pathname.startsWith('/api/shared/')) {
170
+ await handleSharedConversationApi(req, res, url)
171
+ return
172
+ }
173
+
35
174
  // Health check
36
175
  if (req.method === 'GET' && pathname === '/api/health') {
37
- const config = await readProjectConfig()
38
- sendJson(res, 200, {
39
- ok: true,
40
- mode: isDev ? 'development' : 'production',
41
- dataDir,
42
- storageDir,
43
- workspaceRoot: getWorkspaceRoot(),
44
- project: getActiveProject(config),
45
- })
176
+ sendJson(res, 200, await getSystemStatus())
177
+ return
178
+ }
179
+
180
+ if (req.method === 'GET' && pathname === '/api/project/commands') {
181
+ await handleProjectApi(req, res, url)
46
182
  return
47
183
  }
48
184
 
@@ -52,6 +188,12 @@ async function handleApi(req, res, url) {
52
188
  return
53
189
  }
54
190
 
191
+ // Skills
192
+ if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
193
+ await handleSkillsApi(req, res, url)
194
+ return
195
+ }
196
+
55
197
  // Project routes
56
198
  if (pathname === '/api/project' || pathname.startsWith('/api/project/')) {
57
199
  await handleProjectApi(req, res, url)
@@ -64,12 +206,48 @@ async function handleApi(req, res, url) {
64
206
  return
65
207
  }
66
208
 
209
+ // Tool definitions (canonical)
210
+ if (req.method === 'GET' && pathname === '/api/tools') {
211
+ await handleGetTools(req, res)
212
+ return
213
+ }
214
+
67
215
  // Tool routes
68
216
  if (pathname.startsWith('/api/tools/') || (parts[0] === 'api' && parts[1] === 'projects' && parts[3] === 'tools')) {
69
217
  await handleToolApi(req, res, url)
70
218
  return
71
219
  }
72
220
 
221
+ // Agent routes
222
+ if (parts[0] === 'api' && parts[1] === 'agents') {
223
+ await handleAgentApi(req, res, url)
224
+ return
225
+ }
226
+
227
+ // Scheduled task routes
228
+ if (pathname === '/api/scheduled-tasks' || pathname.startsWith('/api/scheduled-tasks/')) {
229
+ await handleScheduledTasksApi(req, res, url)
230
+ return
231
+ }
232
+
233
+ // Backup / import-export routes
234
+ if (pathname === '/api/backup/export' || pathname === '/api/backup/import') {
235
+ await handleBackupApi(req, res, url)
236
+ return
237
+ }
238
+
239
+ // System routes
240
+ if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network') {
241
+ await handleSystemApi(req, res, url, {
242
+ getSystemStatus,
243
+ requestRestart,
244
+ host,
245
+ port,
246
+ remoteEnabled: host !== '127.0.0.1' && host !== 'localhost',
247
+ })
248
+ return
249
+ }
250
+
73
251
  // Storage routes (catch-all)
74
252
  if (parts[0] === 'api' && parts[1] === 'storage') {
75
253
  await handleStorageApi(req, res, url)
@@ -82,64 +260,178 @@ async function handleApi(req, res, url) {
82
260
  }
83
261
 
84
262
  // --- Vite dev server ---
263
+ let viteChild = null
264
+
85
265
  function startVite() {
86
266
  const viteCli = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js')
87
- const child = spawn(process.execPath, [viteCli, '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], {
267
+ viteChild = spawn(process.execPath, [viteCli, '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], {
88
268
  cwd: projectRoot,
89
269
  stdio: 'inherit',
90
270
  shell: false,
91
271
  env: { ...process.env, QUICKFORGE_SERVER_PORT: String(port) },
92
272
  })
93
- child.on('exit', (code) => {
273
+ viteChild.on('exit', (code) => {
94
274
  if (code && code !== 0) process.exitCode = code
95
275
  })
96
- process.on('exit', () => child.kill())
97
- process.on('SIGINT', () => {
98
- child.kill('SIGINT')
99
- process.exit(0)
100
- })
101
- process.on('SIGTERM', () => {
102
- child.kill('SIGTERM')
103
- process.exit(0)
104
- })
276
+ }
277
+
278
+ function stopVite() {
279
+ if (viteChild) {
280
+ viteChild.kill('SIGTERM')
281
+ viteChild = null
282
+ }
283
+ }
284
+
285
+ function isAllowedCorsOrigin(origin) {
286
+ try {
287
+ const parsed = new URL(origin)
288
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false
289
+ if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) return false
290
+ const originPort = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
291
+ return originPort === String(port) || originPort === String(vitePort)
292
+ } catch {
293
+ return false
294
+ }
295
+ }
296
+
297
+ function parseHostHeader(value) {
298
+ if (!value) return null
299
+ try {
300
+ const parsed = new URL(`http://${value}`)
301
+ return { hostname: parsed.hostname, port: parsed.port }
302
+ } catch {
303
+ return null
304
+ }
305
+ }
306
+
307
+ function isAllowedHostHeader(value) {
308
+ const parsed = parseHostHeader(value)
309
+ if (!parsed) return false
310
+ const allowedHosts = new Set(['localhost', '127.0.0.1', host])
311
+ if (process.env.QUICKFORGE_ALLOW_REMOTE === '1' || shareLanEnabled) {
312
+ allowedHosts.add('0.0.0.0')
313
+ allowedHosts.add(parsed.hostname)
314
+ }
315
+ const expectedPort = String(port)
316
+ const hostPort = parsed.port || '80'
317
+ return allowedHosts.has(parsed.hostname) && hostPort === expectedPort
105
318
  }
106
319
 
107
320
  // --- Bootstrap ---
108
321
  const server = createServer(async (req, res) => {
322
+ const startedAt = Date.now()
323
+ res.on('finish', () => {
324
+ const durationMs = Date.now() - startedAt
325
+ logger.info(`${req.method} ${req.url} ${res.statusCode} ${durationMs}ms`)
326
+ })
327
+
328
+ if (!isAllowedHostHeader(req.headers.host)) {
329
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
330
+ res.end(JSON.stringify({ error: 'Forbidden host' }))
331
+ return
332
+ }
333
+
334
+ // Allow direct browser connections to the API server (e.g. SSE from dev mode
335
+ // where the Vite proxy on :5176 would otherwise consume HTTP/1.1 connections).
336
+ const origin = req.headers.origin
337
+ if (origin && isAllowedCorsOrigin(origin)) {
338
+ res.setHeader('Access-Control-Allow-Origin', origin)
339
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
340
+ res.setHeader('Access-Control-Allow-Headers', 'content-type, x-quickforge-action')
341
+ res.setHeader('Access-Control-Max-Age', '86400')
342
+ }
343
+ if (req.method === 'OPTIONS') {
344
+ res.writeHead(origin && !isAllowedCorsOrigin(origin) ? 403 : 204)
345
+ res.end()
346
+ return
347
+ }
109
348
  try {
110
349
  const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`)
350
+ const remoteAddress = req.socket.remoteAddress
351
+ if (
352
+ url.pathname.startsWith('/api/') &&
353
+ !isLoopbackAddress(remoteAddress) &&
354
+ shareLanEnabled &&
355
+ !(url.pathname.startsWith('/api/shared/') || url.pathname === '/api/health')
356
+ ) {
357
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
358
+ res.end(JSON.stringify({ error: 'Remote API access is limited to shared conversation endpoints.' }))
359
+ return
360
+ }
361
+
111
362
  if (url.pathname.startsWith('/api/')) {
112
363
  await handleApi(req, res, url)
113
364
  return
114
365
  }
115
366
 
367
+ if (url.pathname.startsWith('/share/')) {
368
+ await serveStatic(req, res, url)
369
+ return
370
+ }
371
+
372
+ if (
373
+ url.pathname.startsWith('/assets/') ||
374
+ url.pathname === '/favicon.svg' ||
375
+ url.pathname === '/vite.svg'
376
+ ) {
377
+ await serveStatic(req, res, url)
378
+ return
379
+ }
380
+
381
+ if (!isLoopbackAddress(remoteAddress) && shareLanEnabled) {
382
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
383
+ res.end(JSON.stringify({ error: 'Remote access is limited to shared conversation links.' }))
384
+ return
385
+ }
386
+
116
387
  if (isDev) {
117
388
  res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
118
- res.end('QuickForge local API server is running. Open the Vite app at http://127.0.0.1:5176')
389
+ res.end(`QuickForge local API server is running. Open the Vite app at http://127.0.0.1:${vitePort}`)
119
390
  return
120
391
  }
121
392
 
122
393
  await serveStatic(req, res, url)
123
394
  } catch (error) {
124
- console.error(error)
395
+ logger.error(error)
125
396
  sendError(res, error)
126
397
  }
127
398
  })
128
399
 
129
- await migrateLegacyDataDirs()
130
400
  await ensureStorage()
401
+ await resetStaleTaskStatuses()
131
402
  await initializeActiveProject()
132
403
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
404
+ startScheduledTaskRunner()
133
405
 
134
406
  server.listen(port, host, () => {
135
- console.log(`QuickForge local API: http://${host}:${port}`)
136
- console.log(`QuickForge data dir: ${dataDir}`)
137
- console.log(`QuickForge project: ${getWorkspaceRoot()}`)
407
+ logger.info(`QuickForge local API: http://${host}:${port}`)
408
+ if (shareLanEnabled) {
409
+ const lanUrls = getLanUrls(port)
410
+ logger.info(`QuickForge LAN sharing is enabled. Share pages are available at: ${lanUrls.length ? lanUrls.join(', ') : `http://<your-lan-ip>:${port}`}`)
411
+ logger.info('Remote non-share API routes are restricted while QUICKFORGE_SHARE_LAN=1.')
412
+ }
413
+ logger.info(`QuickForge data dir: ${dataDir}`)
414
+ logger.info(`QuickForge project: ${getWorkspaceRoot()}`)
138
415
 
139
416
  if (isDev) {
140
417
  startVite()
141
418
  setTimeout(() => openBrowser(`http://localhost:${vitePort}`), 1000)
419
+ } else if (shareLanEnabled) {
420
+ const lanUrls = getLanUrls(port)
421
+ openBrowser(lanUrls[0] || `http://localhost:${port}`)
142
422
  } else {
143
423
  openBrowser(`http://localhost:${port}`)
144
424
  }
145
425
  })
426
+
427
+ // Graceful shutdown
428
+ async function gracefulShutdown(signal) {
429
+ logger.info(`Received ${signal}, shutting down gracefully...`)
430
+ stopScheduledTaskRunner()
431
+ stopVite()
432
+ await shutdownAgentManager()
433
+ process.exit(0)
434
+ }
435
+
436
+ process.on('SIGINT', (signal) => gracefulShutdown(signal))
437
+ process.on('SIGTERM', (signal) => gracefulShutdown(signal))
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path'
2
2
  import { randomUUID } from 'node:crypto'
3
- import { ensureStorage, projectConfigFile, dataDir } from './storage.mjs'
3
+ import { ensureProjectCache, readProjectConfigData, atomicProjectConfigUpdate, dataDir } from './storage.mjs'
4
4
  import { promises as fs } from 'node:fs'
5
5
  import { setWorkspaceRoot, getWorkspaceRoot, assertDirectory } from './utils/workspace.mjs'
6
+ import { loadSelectedGlobalSkills, loadSelectedProjectSkills, mergeSkills } from './skills.mjs'
6
7
 
7
8
  let defaultWorkspaceRoot = ''
8
9
 
@@ -15,41 +16,24 @@ function projectNameFromPath(dir) {
15
16
  }
16
17
 
17
18
  function defaultProjectConfig() {
18
- const now = new Date().toISOString()
19
- const id = 'default'
20
19
  return {
21
- activeProjectId: id,
22
- projects: [
23
- {
24
- id,
25
- name: projectNameFromPath(defaultWorkspaceRoot),
26
- path: defaultWorkspaceRoot,
27
- lastOpenedAt: now,
28
- },
29
- ],
20
+ activeProjectId: null,
21
+ globalSkills: [],
22
+ projects: [],
30
23
  }
31
24
  }
32
25
 
33
- export async function readProjectConfig() {
34
- await ensureStorage()
35
- const file = projectConfigFile()
36
- try {
37
- const text = await fs.readFile(file, 'utf8')
38
- const parsed = text.trim() ? JSON.parse(text) : defaultProjectConfig()
39
- if (!Array.isArray(parsed.projects) || parsed.projects.length === 0) return defaultProjectConfig()
40
- return parsed
41
- } catch (error) {
42
- if (error?.code === 'ENOENT') return defaultProjectConfig()
43
- throw error
26
+ function normalizeProjectConfig(config) {
27
+ if (!config || typeof config !== 'object') return defaultProjectConfig()
28
+ return {
29
+ activeProjectId: typeof config.activeProjectId === 'string' ? config.activeProjectId : null,
30
+ globalSkills: Array.isArray(config.globalSkills) ? config.globalSkills : [],
31
+ projects: Array.isArray(config.projects) ? config.projects : [],
44
32
  }
45
33
  }
46
34
 
47
- export async function writeProjectConfig(config) {
48
- await ensureStorage()
49
- const file = projectConfigFile()
50
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
51
- await fs.writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
52
- await fs.rename(tmp, file)
35
+ export async function readProjectConfig() {
36
+ return normalizeProjectConfig(await readProjectConfigData())
53
37
  }
54
38
 
55
39
  export function getActiveProject(config) {
@@ -60,28 +44,34 @@ export async function setActiveProjectPath(inputPath) {
60
44
  const resolved = path.resolve(String(inputPath || ''))
61
45
  await assertDirectory(resolved)
62
46
 
63
- const config = await readProjectConfig()
64
47
  const now = new Date().toISOString()
65
- let project = config.projects.find((item) => path.resolve(item.path) === resolved)
66
- if (!project) {
67
- project = {
68
- id: randomUUID(),
69
- name: projectNameFromPath(resolved),
70
- path: resolved,
71
- lastOpenedAt: now,
48
+ let project
49
+
50
+ const updated = await atomicProjectConfigUpdate((config) => {
51
+ project = config.projects.find((item) => path.resolve(item.path) === resolved)
52
+ if (!project) {
53
+ project = {
54
+ id: randomUUID(),
55
+ name: projectNameFromPath(resolved),
56
+ path: resolved,
57
+ lastOpenedAt: now,
58
+ skills: [],
59
+ }
60
+ config.projects.unshift(project)
61
+ } else {
62
+ project.name = projectNameFromPath(resolved)
63
+ project.path = resolved
64
+ project.lastOpenedAt = now
72
65
  }
73
- config.projects.unshift(project)
74
- } else {
75
- project.name = projectNameFromPath(resolved)
76
- project.path = resolved
77
- project.lastOpenedAt = now
78
- }
79
66
 
80
- config.activeProjectId = project.id
81
- config.projects = [project, ...config.projects.filter((item) => item.id !== project.id)].slice(0, 20)
82
- await writeProjectConfig(config)
67
+ config.activeProjectId = project.id
68
+ config.projects = [project, ...config.projects.filter((item) => item.id !== project.id)].slice(0, 20)
69
+ return config
70
+ })
71
+
72
+ await ensureProjectCache(project.id)
83
73
  setWorkspaceRoot(resolved)
84
- return { project, projects: config.projects }
74
+ return { project, projects: updated.projects }
85
75
  }
86
76
 
87
77
  export async function initializeActiveProject() {
@@ -90,6 +80,7 @@ export async function initializeActiveProject() {
90
80
  if (activeProject?.path) {
91
81
  try {
92
82
  await assertDirectory(activeProject.path)
83
+ await ensureProjectCache(activeProject.id)
93
84
  setWorkspaceRoot(path.resolve(activeProject.path))
94
85
  return
95
86
  } catch {
@@ -97,8 +88,7 @@ export async function initializeActiveProject() {
97
88
  }
98
89
  }
99
90
 
100
- const fallback = await setActiveProjectPath(defaultWorkspaceRoot)
101
- setWorkspaceRoot(path.resolve(fallback.project.path))
91
+ // No project configured — leave workspace unset, user will be prompted to add one.
102
92
  }
103
93
 
104
94
  export async function projectContextFromId(projectId) {
@@ -111,15 +101,55 @@ export async function projectContextFromId(projectId) {
111
101
  }
112
102
 
113
103
  await assertDirectory(project.path)
104
+ await ensureProjectCache(project.id)
114
105
  return { project, workspaceRoot: path.resolve(project.path) }
115
106
  }
116
107
 
117
108
  export async function readInstructionsFile(filePath) {
118
- try {
119
- const content = await fs.readFile(filePath, 'utf8')
120
- const trimmed = content.trim()
121
- return trimmed || null
122
- } catch {
123
- return null
109
+ const candidates = filePath.endsWith('AGENTS.md')
110
+ ? [filePath, path.join(path.dirname(filePath), 'agents.md')]
111
+ : [filePath]
112
+
113
+ for (const candidate of candidates) {
114
+ try {
115
+ const content = await fs.readFile(candidate, 'utf8')
116
+ const trimmed = content.trim()
117
+ if (trimmed) return trimmed
118
+ } catch {
119
+ // try next candidate
120
+ }
121
+ }
122
+ return null
123
+ }
124
+
125
+ export async function buildInstructionsPayload(projectId) {
126
+ const config = await readProjectConfig()
127
+ let projectInstructions = null
128
+ let project = projectId ? config.projects.find((item) => item.id === projectId) ?? null : null
129
+
130
+ if (projectId) {
131
+ try {
132
+ const context = await projectContextFromId(projectId)
133
+ project = context.project
134
+ projectInstructions = await readInstructionsFile(path.join(context.workspaceRoot, 'AGENTS.md'))
135
+ } catch {
136
+ // project not found or inaccessible — leave projectInstructions null
137
+ }
138
+ }
139
+
140
+ const globalInstructions = await readInstructionsFile(path.join(dataDir, 'AGENTS.md'))
141
+ const globalSkills = await loadSelectedGlobalSkills(config.globalSkills)
142
+ const projectSkills = project?.skills && project?.path
143
+ ? await loadSelectedProjectSkills(project.skills, project.path)
144
+ : []
145
+ const activeSkills = mergeSkills(globalSkills, projectSkills)
146
+ const stripRuntimeFields = ({ rootDir: _rootDir, instructions: _instructions, location: _location, ...skill }) => skill
147
+
148
+ return {
149
+ global: globalInstructions,
150
+ project: projectInstructions,
151
+ globalSkills: globalSkills.map(stripRuntimeFields),
152
+ projectSkills: projectSkills.map(stripRuntimeFields),
153
+ skills: activeSkills.map(stripRuntimeFields),
124
154
  }
125
155
  }