@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
package/server/index.mjs CHANGED
@@ -2,6 +2,7 @@
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'
@@ -10,42 +11,174 @@ import { setDefaultWorkspaceRoot, initializeActiveProject, readProjectConfig, ge
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 || '127.0.0.1'
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
+ }
25
43
  const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
26
44
  const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
45
+ let restartInProgress = false
27
46
 
28
47
  setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || projectRoot)
29
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
+ }
146
+
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
+ }
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
- configDir,
43
- storageDir,
44
- cacheDir,
45
- logsDir,
46
- workspaceRoot: getWorkspaceRoot(),
47
- project: getActiveProject(config),
48
- })
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)
49
182
  return
50
183
  }
51
184
 
@@ -55,6 +188,12 @@ async function handleApi(req, res, url) {
55
188
  return
56
189
  }
57
190
 
191
+ // Skills
192
+ if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
193
+ await handleSkillsApi(req, res, url)
194
+ return
195
+ }
196
+
58
197
  // Project routes
59
198
  if (pathname === '/api/project' || pathname.startsWith('/api/project/')) {
60
199
  await handleProjectApi(req, res, url)
@@ -67,12 +206,48 @@ async function handleApi(req, res, url) {
67
206
  return
68
207
  }
69
208
 
209
+ // Tool definitions (canonical)
210
+ if (req.method === 'GET' && pathname === '/api/tools') {
211
+ await handleGetTools(req, res)
212
+ return
213
+ }
214
+
70
215
  // Tool routes
71
216
  if (pathname.startsWith('/api/tools/') || (parts[0] === 'api' && parts[1] === 'projects' && parts[3] === 'tools')) {
72
217
  await handleToolApi(req, res, url)
73
218
  return
74
219
  }
75
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
+
76
251
  // Storage routes (catch-all)
77
252
  if (parts[0] === 'api' && parts[1] === 'storage') {
78
253
  await handleStorageApi(req, res, url)
@@ -85,63 +260,178 @@ async function handleApi(req, res, url) {
85
260
  }
86
261
 
87
262
  // --- Vite dev server ---
263
+ let viteChild = null
264
+
88
265
  function startVite() {
89
266
  const viteCli = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js')
90
- 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'], {
91
268
  cwd: projectRoot,
92
269
  stdio: 'inherit',
93
270
  shell: false,
94
271
  env: { ...process.env, QUICKFORGE_SERVER_PORT: String(port) },
95
272
  })
96
- child.on('exit', (code) => {
273
+ viteChild.on('exit', (code) => {
97
274
  if (code && code !== 0) process.exitCode = code
98
275
  })
99
- process.on('exit', () => child.kill())
100
- process.on('SIGINT', () => {
101
- child.kill('SIGINT')
102
- process.exit(0)
103
- })
104
- process.on('SIGTERM', () => {
105
- child.kill('SIGTERM')
106
- process.exit(0)
107
- })
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
108
318
  }
109
319
 
110
320
  // --- Bootstrap ---
111
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
+ }
112
348
  try {
113
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
+
114
362
  if (url.pathname.startsWith('/api/')) {
115
363
  await handleApi(req, res, url)
116
364
  return
117
365
  }
118
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
+
119
387
  if (isDev) {
120
388
  res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
121
- 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}`)
122
390
  return
123
391
  }
124
392
 
125
393
  await serveStatic(req, res, url)
126
394
  } catch (error) {
127
- console.error(error)
395
+ logger.error(error)
128
396
  sendError(res, error)
129
397
  }
130
398
  })
131
399
 
132
400
  await ensureStorage()
401
+ await resetStaleTaskStatuses()
133
402
  await initializeActiveProject()
134
403
  setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
404
+ startScheduledTaskRunner()
135
405
 
136
406
  server.listen(port, host, () => {
137
- console.log(`QuickForge local API: http://${host}:${port}`)
138
- console.log(`QuickForge data dir: ${dataDir}`)
139
- 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()}`)
140
415
 
141
416
  if (isDev) {
142
417
  startVite()
143
418
  setTimeout(() => openBrowser(`http://localhost:${vitePort}`), 1000)
419
+ } else if (shareLanEnabled) {
420
+ const lanUrls = getLanUrls(port)
421
+ openBrowser(lanUrls[0] || `http://localhost:${port}`)
144
422
  } else {
145
423
  openBrowser(`http://localhost:${port}`)
146
424
  }
147
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 { ensureProjectCache, readProjectConfigData, writeProjectConfigData } 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
 
@@ -17,18 +18,22 @@ function projectNameFromPath(dir) {
17
18
  function defaultProjectConfig() {
18
19
  return {
19
20
  activeProjectId: null,
21
+ globalSkills: [],
20
22
  projects: [],
21
23
  }
22
24
  }
23
25
 
24
- export async function readProjectConfig() {
25
- const parsed = await readProjectConfigData()
26
- if (!Array.isArray(parsed.projects) || parsed.projects.length === 0) return defaultProjectConfig()
27
- return parsed
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 : [],
32
+ }
28
33
  }
29
34
 
30
- export async function writeProjectConfig(config) {
31
- await writeProjectConfigData(config)
35
+ export async function readProjectConfig() {
36
+ return normalizeProjectConfig(await readProjectConfigData())
32
37
  }
33
38
 
34
39
  export function getActiveProject(config) {
@@ -39,29 +44,34 @@ export async function setActiveProjectPath(inputPath) {
39
44
  const resolved = path.resolve(String(inputPath || ''))
40
45
  await assertDirectory(resolved)
41
46
 
42
- const config = await readProjectConfig()
43
47
  const now = new Date().toISOString()
44
- let project = config.projects.find((item) => path.resolve(item.path) === resolved)
45
- if (!project) {
46
- project = {
47
- id: randomUUID(),
48
- name: projectNameFromPath(resolved),
49
- path: resolved,
50
- 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
51
65
  }
52
- config.projects.unshift(project)
53
- } else {
54
- project.name = projectNameFromPath(resolved)
55
- project.path = resolved
56
- project.lastOpenedAt = now
57
- }
58
66
 
59
- config.activeProjectId = project.id
60
- config.projects = [project, ...config.projects.filter((item) => item.id !== project.id)].slice(0, 20)
61
- 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
+
62
72
  await ensureProjectCache(project.id)
63
73
  setWorkspaceRoot(resolved)
64
- return { project, projects: config.projects }
74
+ return { project, projects: updated.projects }
65
75
  }
66
76
 
67
77
  export async function initializeActiveProject() {
@@ -96,11 +106,50 @@ export async function projectContextFromId(projectId) {
96
106
  }
97
107
 
98
108
  export async function readInstructionsFile(filePath) {
99
- try {
100
- const content = await fs.readFile(filePath, 'utf8')
101
- const trimmed = content.trim()
102
- return trimmed || null
103
- } catch {
104
- 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),
105
154
  }
106
155
  }
@@ -0,0 +1,51 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Reasoning content cache — preserves DeepSeek V4 reasoning_content across
3
+ // tool-call rounds where the provider API strips it from trailing assistant
4
+ // messages.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export const REASONING_FIELDS = ['reasoning_content', 'reasoning', 'reasoning_text']
8
+
9
+ export function isDeepSeekThinkingModel(model) {
10
+ if (!model) return false
11
+ const provider = String(model.provider ?? '').toLowerCase()
12
+ const baseUrl = String(model.baseUrl ?? '').toLowerCase()
13
+ const modelId = String(model.id ?? '').toLowerCase()
14
+ return (
15
+ modelId.includes('deepseek-v4') &&
16
+ (provider.includes('deepseek') ||
17
+ baseUrl.includes('api.deepseek.com') ||
18
+ baseUrl.includes('deepseek.com'))
19
+ )
20
+ }
21
+
22
+ export function restoreReasoningContentInPayload(payload, messages, model) {
23
+ if (!isDeepSeekThinkingModel(model)) return
24
+ if (!payload?.messages || !Array.isArray(payload.messages)) return
25
+
26
+ // Only the *last* assistant message in the payload can lose its reasoning
27
+ // content (DeepSeek strips reasoning_content from trailing assistant messages
28
+ // when a tool-call round follows). Scan backward to find the first payload
29
+ // assistant without reasoning, then look up its counterpart in agent state.
30
+ const payloadMessages = payload.messages
31
+
32
+ for (let i = payloadMessages.length - 1; i >= 0; i--) {
33
+ const msg = payloadMessages[i]
34
+ if (!msg || typeof msg !== 'object' || msg.role !== 'assistant') continue
35
+ if (msg.reasoning_content || msg.reasoning || msg.reasoning_text) break // already has reasoning — stop
36
+
37
+ // Find the *last* assistant message from agent state that matches positionally
38
+ const assistantIndex = payloadMessages.slice(0, i + 1).filter((m) => m && typeof m === 'object' && m.role === 'assistant').length - 1
39
+ const agentAssistants = messages.filter((m) => m.role === 'assistant')
40
+ const cached = agentAssistants[assistantIndex]
41
+ if (!cached) break
42
+
43
+ for (const field of REASONING_FIELDS) {
44
+ if (cached[field]) {
45
+ msg[field] = cached[field]
46
+ break
47
+ }
48
+ }
49
+ break // Only patch the first (last-in-payload) assistant missing reasoning
50
+ }
51
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+
4
+ const [oldPidArg, serverScript, cwd, ...serverArgs] = process.argv.slice(2)
5
+ const oldPid = Number(oldPidArg)
6
+
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => setTimeout(resolve, ms))
9
+ }
10
+
11
+ function isProcessRunning(pid) {
12
+ if (!pid) return false
13
+ try {
14
+ process.kill(pid, 0)
15
+ return true
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ for (let i = 0; i < 600 && isProcessRunning(oldPid); i += 1) {
22
+ await sleep(100)
23
+ }
24
+
25
+ const child = spawn(process.execPath, [serverScript, ...serverArgs], {
26
+ cwd: cwd || undefined,
27
+ detached: true,
28
+ stdio: 'ignore',
29
+ windowsHide: true,
30
+ shell: false,
31
+ env: {
32
+ ...process.env,
33
+ QUICKFORGE_NO_OPEN: '1',
34
+ QUICKFORGE_RESTARTED_FROM_UI: '1',
35
+ },
36
+ })
37
+
38
+ child.unref()