@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.
- package/README.md +1 -1
- package/bin/quickforge.mjs +72 -7
- package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-D93MV5Cx.js → google-vertex-DSMuB4YB.js} +1 -1
- package/dist/assets/icons-BsZ9PlYY.js +1 -0
- package/dist/assets/index-BqFfVQJM.css +3 -0
- package/dist/assets/index-DoraECXN.js +3187 -0
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-_z7sua8J.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
- package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
- package/dist/index.html +7 -3
- package/package.json +14 -13
- package/server/agent-manager.mjs +1053 -0
- package/server/conversation-compaction.mjs +302 -0
- package/server/custom-commands.mjs +344 -0
- package/server/index.mjs +322 -32
- package/server/project-config.mjs +80 -31
- package/server/reasoning-cache.mjs +51 -0
- package/server/restart-supervisor.mjs +38 -0
- package/server/routes/agent.mjs +323 -0
- package/server/routes/backup.mjs +250 -0
- package/server/routes/instructions.mjs +6 -17
- package/server/routes/project.mjs +46 -10
- package/server/routes/scheduled-tasks.mjs +424 -0
- package/server/routes/shared-conversation.mjs +404 -0
- package/server/routes/shares.mjs +84 -0
- package/server/routes/skills.mjs +145 -0
- package/server/routes/static.mjs +4 -3
- package/server/routes/storage.mjs +58 -10
- package/server/routes/system.mjs +35 -0
- package/server/routes/tools.mjs +53 -2
- package/server/session-utils.mjs +102 -0
- package/server/share-store.mjs +468 -0
- package/server/skills.mjs +539 -0
- package/server/storage.mjs +247 -6
- package/server/system-prompt.mjs +67 -0
- package/server/tools/definitions.mjs +120 -0
- package/server/tools/index.mjs +167 -46
- package/server/utils/logger.mjs +34 -0
- package/server/utils/network.mjs +38 -0
- package/server/utils/platform.mjs +30 -0
- package/server/utils/response.mjs +8 -1
- package/skills/ai-context-package/SKILL.md +104 -0
- package/skills/ai-context-package/skill.json +9 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/code-review/skill.json +9 -0
- package/skills/frontend-react/SKILL.md +22 -0
- package/skills/frontend-react/skill.json +9 -0
- package/skills/quickforge-project/SKILL.md +22 -0
- package/skills/quickforge-project/skill.json +9 -0
- package/dist/assets/chunk-62oNxeRG.js +0 -1
- package/dist/assets/confirm-dialog-4mZt9XEq.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-Bq6VHkyY.js +0 -3048
- package/dist/assets/index-D7uXa1RT.css +0 -3
- package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
- package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
- /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
- /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
- /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
- /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 {
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
+
viteChild.on('exit', (code) => {
|
|
97
274
|
if (code && code !== 0) process.exitCode = code
|
|
98
275
|
})
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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,
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
await
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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()
|