@shawnstack/quickforge 1.7.5 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +1 -0
  2. package/dist/assets/ChatPanelHost-Dul1ahKj.js +307 -0
  3. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +1 -0
  4. package/dist/assets/PluginsPage-CPE5tbbf.js +1 -0
  5. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +2 -0
  6. package/dist/assets/SettingsWorkspacePage-BE2T4d3P.js +1696 -0
  7. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +1 -0
  8. package/dist/assets/SharedConversationPage-BK9XJnEe.js +1 -0
  9. package/dist/assets/TerminalDock-CL7HU9DI.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-D9kdnLDd.js → WorkspaceInspector-MNBLIKMn.js} +5 -5
  11. package/dist/assets/{dist-DLKC_odd.js → dist-yDHbi5ux.js} +1 -1
  12. package/dist/assets/{esm-Df4edDpj.js → esm-sdsK6gaB.js} +2 -2
  13. package/dist/assets/{icons-CXPP1fFT.js → icons-2D18YbSI.js} +1 -1
  14. package/dist/assets/index-BTPzyq7d.js +65 -0
  15. package/dist/assets/index-BiCTgUlY.css +3 -0
  16. package/dist/assets/{mcp-servers-dialog-BUSPvaqI.js → mcp-servers-dialog-GY7sdfIr.js} +2 -2
  17. package/dist/assets/{logger-8REuLPGK.js → model-display-label-C6-0eDxF.js} +1 -1
  18. package/dist/assets/{monaco-D6OhXgjg.js → monaco-DvXErxp2.js} +1 -1
  19. package/dist/assets/{react-vendor-1KC5PyRx.js → react-vendor-3UaA-C9W.js} +1 -1
  20. package/dist/assets/{skills-dialog-BThnIUBs.js → skills-dialog-C1fJ1Jl7.js} +3 -3
  21. package/dist/assets/{web-DoV8MSMn.js → web-B5qgZJRv.js} +1 -1
  22. package/dist/index.html +8 -8
  23. package/dist/sw.js +17 -0
  24. package/package.json +2 -1
  25. package/runtime-assets/agent/darwin-arm64/qf-agent +0 -0
  26. package/runtime-assets/agent/darwin-x64/qf-agent +0 -0
  27. package/runtime-assets/agent/linux-arm64/qf-agent +0 -0
  28. package/runtime-assets/agent/linux-x64/qf-agent +0 -0
  29. package/runtime-assets/agent/win32-x64/qf-agent.exe +0 -0
  30. package/server/access-policy.mjs +11 -0
  31. package/server/agent-manager.mjs +349 -48
  32. package/server/agent-profiles.mjs +27 -8
  33. package/server/auto-compaction.mjs +78 -9
  34. package/server/cloud/client.mjs +4 -7
  35. package/server/cloud/config.mjs +9 -16
  36. package/server/cloud/credential-store.mjs +10 -3
  37. package/server/cloud/identity.mjs +17 -60
  38. package/server/cloud/models.mjs +1 -1
  39. package/server/cloud/qf-agent-process.mjs +571 -0
  40. package/server/cloud/runtime.mjs +11 -5
  41. package/server/cloud/service-config.mjs +8 -4
  42. package/server/context-usage.mjs +11 -4
  43. package/server/index.mjs +96 -37
  44. package/server/mcp/registry.mjs +8 -7
  45. package/server/message-converters.mjs +40 -19
  46. package/server/model-catalog.mjs +11 -4
  47. package/server/opencode-acp-agent.mjs +1151 -0
  48. package/server/public-api.mjs +11 -5
  49. package/server/routes/agent-profiles.mjs +6 -3
  50. package/server/routes/agent.mjs +43 -3
  51. package/server/routes/backup.mjs +5 -4
  52. package/server/routes/cloud.mjs +54 -35
  53. package/server/routes/shares.mjs +7 -8
  54. package/server/routes/storage.mjs +9 -24
  55. package/server/routes/system.mjs +7 -4
  56. package/server/utils/process-tree.mjs +78 -0
  57. package/dist/assets/AgentProfilesPage-XNjuqJs7.js +0 -1
  58. package/dist/assets/ChatPanelHost-CuF-PZpc.js +0 -292
  59. package/dist/assets/CloudAccountSettingsPage-GJSoi6sO.js +0 -1
  60. package/dist/assets/PluginsPage-BQgp0qN-.js +0 -1
  61. package/dist/assets/ScheduledTasksPage-DdekATzq.js +0 -2
  62. package/dist/assets/SettingsWorkspacePage-Cb54Ic91.js +0 -1639
  63. package/dist/assets/ShareLinksSettingsPage-C75WJpaF.js +0 -1
  64. package/dist/assets/SharedConversationPage-CjMtSQTW.js +0 -1
  65. package/dist/assets/TerminalDock-D4zJBJc4.js +0 -2
  66. package/dist/assets/index-_ZhK6bvp.js +0 -65
  67. package/dist/assets/index-iVV2bP_f.css +0 -3
@@ -0,0 +1,571 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { chmodSync, existsSync, promises as fs, statSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dataDir } from '../storage.mjs'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const __dirname = path.dirname(__filename)
9
+ const projectRoot = path.resolve(__dirname, '../..')
10
+ const RESTART_DELAYS_MS = [1_000, 5_000, 30_000]
11
+ const MAX_CONSECUTIVE_RESTARTS = 5
12
+ const STABLE_RUN_MS = 60_000
13
+ const VERSION_TIMEOUT_MS = 5_000
14
+ const STOP_TIMEOUT_MS = 3_000
15
+
16
+ let child = null
17
+ let childStartedAt = 0
18
+ let lockPath = null
19
+ let lockOwnerPid = null
20
+ let lockCleanupPromise = null
21
+ let restartTimer = null
22
+ let restartCount = 0
23
+ let stopRequested = false
24
+ let startPromise = null
25
+ let stopPromise = null
26
+ let lifecycleGeneration = 0
27
+ let versionCheckChild = null
28
+ let launchOptions = null
29
+ let status = createStatus()
30
+
31
+ function createStatus(overrides = {}) {
32
+ return {
33
+ enabled: process.env.QUICKFORGE_QF_AGENT_ENABLED !== '0',
34
+ status: 'stopped',
35
+ serverUrl: null,
36
+ pid: null,
37
+ verificationUriComplete: null,
38
+ error: null,
39
+ updatedAt: new Date().toISOString(),
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ function setStatus(next) {
45
+ status = createStatus({ ...status, ...next, updatedAt: new Date().toISOString() })
46
+ }
47
+
48
+ function nodePlatformTarget(platform = process.platform) {
49
+ if (platform === 'win32') return 'windows'
50
+ return platform
51
+ }
52
+
53
+ function nodeArchTarget(arch = process.arch) {
54
+ if (arch === 'x64') return 'amd64'
55
+ if (arch === 'ia32') return '386'
56
+ return arch
57
+ }
58
+
59
+ function runtimeAssetName(platform = process.platform) {
60
+ return platform === 'win32' ? 'qf-agent.exe' : 'qf-agent'
61
+ }
62
+
63
+ function isDevelopmentRuntime(env = process.env) {
64
+ return process.argv.includes('--dev')
65
+ || env.NODE_ENV === 'development'
66
+ || env.QUICKFORGE_QF_AGENT_DEV_FALLBACK === '1'
67
+ || process.defaultApp === true
68
+ }
69
+
70
+ export function resolveQfAgentExecutable({
71
+ env = process.env,
72
+ platform = process.platform,
73
+ arch = process.arch,
74
+ root = projectRoot,
75
+ development = isDevelopmentRuntime(env),
76
+ pathExists = existsSync,
77
+ } = {}) {
78
+ const name = runtimeAssetName(platform)
79
+ const candidates = []
80
+ if (env.QUICKFORGE_QF_AGENT_PATH) candidates.push(path.resolve(env.QUICKFORGE_QF_AGENT_PATH))
81
+ candidates.push(path.join(root, 'runtime-assets', 'agent', `${platform}-${arch}`, name))
82
+ if (development && platform === 'win32') {
83
+ candidates.push(path.resolve(root, '..', 'quickforge-cloud', 'bin', 'agent.exe'))
84
+ }
85
+ return candidates.find((candidate) => pathExists(candidate)) || null
86
+ }
87
+
88
+ function ensureAgentExecutable(executable, platform = process.platform) {
89
+ if (platform === 'win32') return
90
+ try {
91
+ if (!(statSync(executable).mode & 0o111)) {
92
+ chmodSync(executable, 0o755)
93
+ }
94
+ } catch {
95
+ // Best effort: spawn will surface real permission errors through status.
96
+ }
97
+ }
98
+
99
+ function identityDirectory(serverUrl) {
100
+ if (process.env.QUICKFORGE_QF_AGENT_IDENTITY_DIR) {
101
+ return path.resolve(process.env.QUICKFORGE_QF_AGENT_IDENTITY_DIR)
102
+ }
103
+ const runtimeKind = String(process.env.QUICKFORGE_RUNTIME_KIND || 'server').trim() || 'server'
104
+ const safeRuntimeKind = runtimeKind.replace(/[^a-zA-Z0-9_-]/g, '-')
105
+ const parsed = new URL(serverUrl)
106
+ const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
107
+ return path.join(dataDir, 'remote-agent', `${safeRuntimeKind}-${port}`)
108
+ }
109
+
110
+ function isProcessAlive(pid) {
111
+ if (!Number.isInteger(pid) || pid <= 0) return false
112
+ try {
113
+ process.kill(pid, 0)
114
+ return true
115
+ } catch (error) {
116
+ return error?.code === 'EPERM'
117
+ }
118
+ }
119
+
120
+ async function readLock(file) {
121
+ try {
122
+ const value = JSON.parse(await fs.readFile(file, 'utf8'))
123
+ return value && typeof value === 'object' ? value : null
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ async function acquireLock(identityDir, ownerPid, serverUrl) {
130
+ await fs.mkdir(path.dirname(identityDir), { recursive: true })
131
+ const file = `${identityDir}.lock.json`
132
+
133
+ for (let attempt = 0; attempt < 2; attempt += 1) {
134
+ try {
135
+ const handle = await fs.open(file, 'wx', 0o600)
136
+ try {
137
+ await handle.writeFile(`${JSON.stringify({ ownerPid, agentPid: null, serverUrl })}\n`, 'utf8')
138
+ } finally {
139
+ await handle.close()
140
+ }
141
+ lockPath = file
142
+ lockOwnerPid = ownerPid
143
+ return { acquired: true }
144
+ } catch (error) {
145
+ if (error?.code !== 'EEXIST') throw error
146
+ const existing = await readLock(file)
147
+ if (existing && isProcessAlive(Number(existing.ownerPid)) && Number(existing.ownerPid) !== ownerPid) {
148
+ return { acquired: false, conflict: true }
149
+ }
150
+ try {
151
+ await fs.unlink(file)
152
+ } catch (unlinkError) {
153
+ if (unlinkError?.code !== 'ENOENT') throw unlinkError
154
+ }
155
+ }
156
+ }
157
+ return { acquired: false, conflict: true }
158
+ }
159
+
160
+ async function updateOwnLock(agentPid, serverUrl) {
161
+ if (!lockPath) return
162
+ const temporary = `${lockPath}.${process.pid}.${Date.now()}.tmp`
163
+ try {
164
+ await fs.writeFile(temporary, `${JSON.stringify({ ownerPid: lockOwnerPid, agentPid, serverUrl })}\n`, { encoding: 'utf8', mode: 0o600 })
165
+ await fs.rename(temporary, lockPath)
166
+ } catch (error) {
167
+ await fs.unlink(temporary).catch(() => {})
168
+ throw error
169
+ }
170
+ }
171
+
172
+ async function clearOwnLock() {
173
+ if (lockCleanupPromise) return lockCleanupPromise
174
+ const file = lockPath
175
+ const ownerPid = lockOwnerPid
176
+ lockPath = null
177
+ lockOwnerPid = null
178
+ if (!file) return
179
+
180
+ const pending = (async () => {
181
+ const existing = await readLock(file)
182
+ if (existing && Number(existing.ownerPid) !== ownerPid) return
183
+ try {
184
+ await fs.unlink(file)
185
+ } catch (error) {
186
+ if (error?.code !== 'ENOENT') throw error
187
+ }
188
+ })().finally(() => {
189
+ if (lockCleanupPromise === pending) lockCleanupPromise = null
190
+ })
191
+ lockCleanupPromise = pending
192
+ return pending
193
+ }
194
+
195
+ function isCurrentLifecycle(generation) {
196
+ return !stopRequested && generation === lifecycleGeneration && launchOptions !== null
197
+ }
198
+
199
+ async function terminateChild(activeChild) {
200
+ if (!activeChild) return
201
+ const exited = new Promise((resolve) => activeChild.once('exit', resolve))
202
+ try { activeChild.kill('SIGTERM') } catch { /* best effort */ }
203
+ await Promise.race([
204
+ exited,
205
+ new Promise((resolve) => setTimeout(resolve, STOP_TIMEOUT_MS)),
206
+ ])
207
+ if (process.platform === 'win32' && isProcessAlive(activeChild.pid)) {
208
+ await new Promise((resolve) => {
209
+ const killer = spawn('taskkill', ['/PID', String(activeChild.pid), '/T', '/F'], {
210
+ windowsHide: true,
211
+ shell: false,
212
+ stdio: 'ignore',
213
+ })
214
+ killer.once('error', resolve)
215
+ killer.once('exit', resolve)
216
+ })
217
+ }
218
+ }
219
+
220
+ function safeErrorMessage(value) {
221
+ const text = String(value || '').trim()
222
+ if (!text) return null
223
+ return text
224
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
225
+ .replace(/\b(token|secret|password|api[_-]?key|private[_-]?key)\s*[:=]\s*\S+/gi, '$1=[redacted]')
226
+ .replace(/https?:\/\/\S+/gi, '[url]')
227
+ .replace(/[A-Za-z]:\\[^\s"']+/g, '[path]')
228
+ .replace(/\/(?:[^\s/]+\/)+[^\s"']*/g, '[path]')
229
+ .slice(0, 500)
230
+ }
231
+
232
+ function safeVerificationUrl(value) {
233
+ if (typeof value !== 'string' || !value.trim()) return null
234
+ try {
235
+ const parsed = new URL(value)
236
+ return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : null
237
+ } catch {
238
+ return null
239
+ }
240
+ }
241
+
242
+ export function parseQfAgentLogLine(line) {
243
+ const text = String(line || '').trim()
244
+ if (!text) return null
245
+ let record
246
+ try {
247
+ record = JSON.parse(text)
248
+ } catch {
249
+ return null
250
+ }
251
+ if (!record || typeof record !== 'object') return null
252
+
253
+ const message = typeof record.msg === 'string' ? record.msg : typeof record.message === 'string' ? record.message : ''
254
+ const result = {}
255
+ if (message.includes('等待设备授权')) {
256
+ result.status = 'authorizing'
257
+ result.verificationUriComplete = safeVerificationUrl(record.verificationUriComplete)
258
+ } else if (message.includes('信令已连接')) {
259
+ result.status = 'running'
260
+ result.verificationUriComplete = null
261
+ } else if (message.includes('信令连接中断') || message.includes('重连')) {
262
+ result.status = 'starting'
263
+ }
264
+
265
+ const level = String(record.level || record.severity || '').toLowerCase()
266
+ if (['error', 'fatal', 'panic'].includes(level)) result.error = safeErrorMessage(message) || 'qf-agent reported an error.'
267
+ return Object.keys(result).length ? result : null
268
+ }
269
+
270
+ function consumeLines(stream, onLine) {
271
+ let buffered = ''
272
+ stream?.setEncoding('utf8')
273
+ stream?.on('data', (chunk) => {
274
+ buffered += chunk
275
+ let newline = buffered.indexOf('\n')
276
+ while (newline >= 0) {
277
+ onLine(buffered.slice(0, newline).replace(/\r$/, ''))
278
+ buffered = buffered.slice(newline + 1)
279
+ newline = buffered.indexOf('\n')
280
+ }
281
+ })
282
+ stream?.on('end', () => {
283
+ if (buffered) onLine(buffered.replace(/\r$/, ''))
284
+ })
285
+ }
286
+
287
+ function parseVersionOutput(output) {
288
+ const lines = String(output || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
289
+ for (const line of lines.reverse()) {
290
+ try {
291
+ const value = JSON.parse(line)
292
+ if (value && typeof value === 'object') return value
293
+ } catch {
294
+ // Ignore non-JSON diagnostic lines.
295
+ }
296
+ }
297
+ return null
298
+ }
299
+
300
+ export function validateQfAgentVersion(version, { platform = process.platform, arch = process.arch } = {}) {
301
+ const expectedTarget = `${nodePlatformTarget(platform)}-${nodeArchTarget(arch)}`
302
+ const actualTarget = String(version?.target || '').trim().toLowerCase().replace(/[/_]/g, '-')
303
+ if (Number(version?.protocolVersion) !== 1) {
304
+ throw new Error('qf-agent protocol version is not supported.')
305
+ }
306
+ if (actualTarget !== expectedTarget) {
307
+ throw new Error(`qf-agent target is incompatible with ${expectedTarget}.`)
308
+ }
309
+ return true
310
+ }
311
+
312
+ async function checkVersion(executable) {
313
+ const result = await new Promise((resolve, reject) => {
314
+ const versionChild = spawn(executable, ['--version'], {
315
+ windowsHide: true,
316
+ shell: false,
317
+ stdio: ['ignore', 'pipe', 'pipe'],
318
+ })
319
+ versionCheckChild = versionChild
320
+ let stdout = ''
321
+ let stderr = ''
322
+ const finish = (callback, value) => {
323
+ if (versionCheckChild === versionChild) versionCheckChild = null
324
+ clearTimeout(timer)
325
+ callback(value)
326
+ }
327
+ const timer = setTimeout(() => {
328
+ try { versionChild.kill('SIGKILL') } catch { /* best effort */ }
329
+ finish(reject, new Error('qf-agent version check timed out.'))
330
+ }, VERSION_TIMEOUT_MS)
331
+ timer.unref?.()
332
+ versionChild.stdout?.setEncoding('utf8')
333
+ versionChild.stderr?.setEncoding('utf8')
334
+ versionChild.stdout?.on('data', (chunk) => { stdout += chunk })
335
+ versionChild.stderr?.on('data', (chunk) => { stderr += chunk })
336
+ versionChild.once('error', (error) => finish(reject, error))
337
+ versionChild.once('exit', (code) => {
338
+ if (code !== 0) {
339
+ finish(reject, new Error(safeErrorMessage(stderr) || `qf-agent version check exited with code ${code}.`))
340
+ return
341
+ }
342
+ finish(resolve, stdout)
343
+ })
344
+ })
345
+ const version = parseVersionOutput(result)
346
+ if (!version) throw new Error('qf-agent returned an invalid version response.')
347
+ validateQfAgentVersion(version)
348
+ }
349
+
350
+ function scheduleRestart(generation) {
351
+ if (!isCurrentLifecycle(generation) || restartTimer) return
352
+ if (restartCount >= MAX_CONSECUTIVE_RESTARTS) {
353
+ setStatus({ status: 'error', pid: null, error: status.error || 'qf-agent stopped after repeated failures.' })
354
+ return
355
+ }
356
+ const delay = RESTART_DELAYS_MS[Math.min(restartCount, RESTART_DELAYS_MS.length - 1)]
357
+ restartCount += 1
358
+ setStatus({ status: 'starting', pid: null })
359
+ restartTimer = setTimeout(() => {
360
+ restartTimer = null
361
+ if (!isCurrentLifecycle(generation)) return
362
+ void beginLaunch(generation).catch((error) => {
363
+ if (!isCurrentLifecycle(generation)) return
364
+ setStatus({ status: 'error', pid: null, error: safeErrorMessage(error?.message) || 'qf-agent failed to restart.' })
365
+ scheduleRestart(generation)
366
+ })
367
+ }, delay)
368
+ restartTimer.unref?.()
369
+ }
370
+
371
+ async function launchQfAgent(generation) {
372
+ const options = launchOptions
373
+ if (!options || !isCurrentLifecycle(generation)) return getQfAgentStatus()
374
+ const { serverUrl, ownerPid, cloudUrl } = options
375
+ const executable = resolveQfAgentExecutable()
376
+ if (!executable) {
377
+ if (isCurrentLifecycle(generation)) {
378
+ setStatus({ status: 'unavailable', serverUrl, pid: null, verificationUriComplete: null, error: null })
379
+ }
380
+ return getQfAgentStatus()
381
+ }
382
+ ensureAgentExecutable(executable)
383
+
384
+ setStatus({ status: 'starting', serverUrl, pid: null, verificationUriComplete: null })
385
+ try {
386
+ await checkVersion(executable)
387
+ } catch (error) {
388
+ if (isCurrentLifecycle(generation)) {
389
+ setStatus({ status: 'error', pid: null, error: safeErrorMessage(error?.message) || 'qf-agent version check failed.' })
390
+ }
391
+ return getQfAgentStatus()
392
+ }
393
+ if (!isCurrentLifecycle(generation)) return getQfAgentStatus()
394
+
395
+ const identityDir = identityDirectory(serverUrl)
396
+ const lock = await acquireLock(identityDir, ownerPid, serverUrl)
397
+ if (!lock.acquired) {
398
+ if (isCurrentLifecycle(generation)) setStatus({ status: 'conflict', pid: null, error: null })
399
+ return getQfAgentStatus()
400
+ }
401
+ if (!isCurrentLifecycle(generation)) {
402
+ await clearOwnLock().catch(() => {})
403
+ return getQfAgentStatus()
404
+ }
405
+ await fs.mkdir(identityDir, { recursive: true })
406
+ if (!isCurrentLifecycle(generation)) {
407
+ await clearOwnLock().catch(() => {})
408
+ return getQfAgentStatus()
409
+ }
410
+
411
+ let spawned
412
+ try {
413
+ spawned = spawn(executable, [], {
414
+ windowsHide: true,
415
+ shell: false,
416
+ stdio: ['ignore', 'pipe', 'pipe'],
417
+ env: {
418
+ ...process.env,
419
+ QF_CLOUD_URL: cloudUrl,
420
+ QF_CLOUD_AGENT_DATA_DIR: identityDir,
421
+ QF_CLOUD_AGENT_QF_URL: serverUrl,
422
+ QF_CLOUD_AGENT_OWNER_PID: String(ownerPid),
423
+ },
424
+ })
425
+ await new Promise((resolve, reject) => {
426
+ spawned.once('spawn', resolve)
427
+ spawned.once('error', reject)
428
+ })
429
+ } catch (error) {
430
+ await clearOwnLock().catch(() => {})
431
+ throw error
432
+ }
433
+
434
+ child = spawned
435
+ childStartedAt = Date.now()
436
+ spawned.once('exit', () => {
437
+ if (child !== spawned) return
438
+ const stableRun = Date.now() - childStartedAt >= STABLE_RUN_MS
439
+ child = null
440
+ childStartedAt = 0
441
+ if (stableRun) restartCount = 0
442
+ void clearOwnLock().then(() => {
443
+ if (!isCurrentLifecycle(generation)) {
444
+ setStatus({ status: 'stopped', pid: null, verificationUriComplete: null })
445
+ return
446
+ }
447
+ scheduleRestart(generation)
448
+ }).catch(() => {
449
+ if (isCurrentLifecycle(generation)) scheduleRestart(generation)
450
+ })
451
+ })
452
+
453
+ const onLine = (line) => {
454
+ if (!isCurrentLifecycle(generation) || child !== spawned) return
455
+ const update = parseQfAgentLogLine(line)
456
+ if (update) setStatus(update)
457
+ }
458
+ consumeLines(spawned.stdout, onLine)
459
+ consumeLines(spawned.stderr, onLine)
460
+
461
+ try {
462
+ await updateOwnLock(spawned.pid, serverUrl)
463
+ } catch (error) {
464
+ if (child === spawned) child = null
465
+ await terminateChild(spawned)
466
+ await clearOwnLock().catch(() => {})
467
+ throw error
468
+ }
469
+
470
+ if (!isCurrentLifecycle(generation) || child !== spawned) {
471
+ if (child === spawned) child = null
472
+ await terminateChild(spawned)
473
+ await clearOwnLock().catch(() => {})
474
+ return getQfAgentStatus()
475
+ }
476
+
477
+ setStatus({ status: 'starting', pid: spawned.pid })
478
+ return getQfAgentStatus()
479
+ }
480
+
481
+ function beginLaunch(generation) {
482
+ if (startPromise) return startPromise
483
+ const pending = launchQfAgent(generation).finally(() => {
484
+ if (startPromise === pending) startPromise = null
485
+ })
486
+ startPromise = pending
487
+ return pending
488
+ }
489
+
490
+ export async function startQfAgent({ serverUrl, ownerPid, cloudUrl }) {
491
+ if (process.env.QUICKFORGE_QF_AGENT_ENABLED === '0') {
492
+ setStatus({ enabled: false, status: 'disabled', serverUrl: null, pid: null, verificationUriComplete: null, error: null })
493
+ return getQfAgentStatus()
494
+ }
495
+ if (stopPromise) await stopPromise
496
+ if (child || restartTimer) return getQfAgentStatus()
497
+ if (startPromise) return startPromise
498
+
499
+ const options = {
500
+ serverUrl: new URL(serverUrl).href,
501
+ ownerPid: Number(ownerPid),
502
+ cloudUrl: new URL(cloudUrl).href,
503
+ }
504
+ if (!Number.isInteger(options.ownerPid) || options.ownerPid <= 0) {
505
+ setStatus({ status: 'error', error: 'qf-agent owner PID is invalid.' })
506
+ return getQfAgentStatus()
507
+ }
508
+ lifecycleGeneration += 1
509
+ const generation = lifecycleGeneration
510
+ launchOptions = options
511
+ stopRequested = false
512
+ stopPromise = null
513
+ restartCount = 0
514
+ setStatus({ enabled: true, error: null })
515
+ try {
516
+ return await beginLaunch(generation)
517
+ } catch (error) {
518
+ if (isCurrentLifecycle(generation)) {
519
+ setStatus({ status: 'error', pid: null, error: safeErrorMessage(error?.message) || 'qf-agent failed to start.' })
520
+ }
521
+ return getQfAgentStatus()
522
+ }
523
+ }
524
+
525
+ export async function stopQfAgent({ disabled = false } = {}) {
526
+ if (stopPromise) {
527
+ const result = await stopPromise
528
+ if (disabled && result.status !== 'disabled') {
529
+ setStatus({ enabled: false, status: 'disabled', serverUrl: null, pid: null, verificationUriComplete: null, error: null })
530
+ return getQfAgentStatus()
531
+ }
532
+ return result
533
+ }
534
+ stopRequested = true
535
+ lifecycleGeneration += 1
536
+ launchOptions = null
537
+ if (restartTimer) {
538
+ clearTimeout(restartTimer)
539
+ restartTimer = null
540
+ }
541
+ const activeVersionCheck = versionCheckChild
542
+ if (activeVersionCheck) {
543
+ try { activeVersionCheck.kill('SIGKILL') } catch { /* best effort */ }
544
+ }
545
+
546
+ stopPromise = (async () => {
547
+ const pendingStart = startPromise
548
+ if (pendingStart) await pendingStart.catch(() => {})
549
+ const activeChild = child
550
+ if (activeChild) {
551
+ if (child === activeChild) child = null
552
+ await terminateChild(activeChild)
553
+ }
554
+ await clearOwnLock().catch(() => {})
555
+ setStatus({ enabled: disabled ? false : status.enabled, status: disabled ? 'disabled' : 'stopped', serverUrl: null, pid: null, verificationUriComplete: null, error: null })
556
+ return getQfAgentStatus()
557
+ })()
558
+ return stopPromise
559
+ }
560
+
561
+ export function getQfAgentStatus() {
562
+ return {
563
+ enabled: status.enabled,
564
+ status: status.status,
565
+ serverUrl: status.serverUrl,
566
+ pid: status.pid,
567
+ verificationUriComplete: status.verificationUriComplete,
568
+ error: status.error,
569
+ updatedAt: status.updatedAt,
570
+ }
571
+ }
@@ -13,23 +13,23 @@ let runtimeGeneration = 0
13
13
  let runtimeFactory = createCloudRuntime
14
14
 
15
15
  function runtimeKey(config, generation = runtimeGeneration) {
16
- return `${generation}:${config?.baseUrl?.href || ''}:${config?.timeoutMs || ''}`
16
+ return `${generation}:${config?.enabled === true}:${config?.baseUrl?.href || ''}:${config?.timeoutMs || ''}`
17
17
  }
18
18
 
19
19
  export function createCloudRuntime({ config = readCloudConfig(), store, client } = {}) {
20
- if (!config?.enabled) return { enabled: false, config }
21
20
  const credentialStore = store || createCloudCredentialStore({ clientVersion: process.env.npm_package_version || 'unknown' })
21
+ if (!config?.baseUrl) return { enabled: false, config, store: credentialStore }
22
22
  const cloudClient = client || new CloudClient(config)
23
23
  const identity = new CloudIdentityManager({ client: cloudClient, store: credentialStore, serviceUrl: config.baseUrl.href })
24
24
  const models = new ManagedCloudModels({ identity })
25
- return { enabled: true, config, store: credentialStore, client: cloudClient, identity, models }
25
+ return { enabled: config.enabled === true, config, store: credentialStore, client: cloudClient, identity, models }
26
26
  }
27
27
 
28
28
  async function resolvedRuntimeConfig() {
29
29
  const service = await readCloudServiceConfig()
30
30
  const timeoutValue = Number(process.env.QUICKFORGE_CLOUD_TIMEOUT_MS || 10_000)
31
31
  return {
32
- enabled: Boolean(service.baseUrl),
32
+ enabled: service.enabled === true && Boolean(service.baseUrl),
33
33
  baseUrl: service.baseUrl,
34
34
  cloudUrl: service.cloudUrl,
35
35
  source: service.source,
@@ -76,7 +76,13 @@ export function invalidateCloudRuntime() {
76
76
  export async function resolveManagedCloudProvider(model, signal) {
77
77
  if (!isManagedCloudModel(model)) return undefined
78
78
  const runtime = await getCloudRuntime()
79
- if (!runtime.enabled) throw new Error('QuickForge Cloud is not configured.')
79
+ if (!runtime.enabled) {
80
+ const error = new Error(runtime?.config?.baseUrl && runtime?.config?.enabled !== true
81
+ ? 'QuickForge Cloud is disabled.'
82
+ : 'QuickForge Cloud is not configured.')
83
+ error.code = runtime?.config?.baseUrl && runtime?.config?.enabled !== true ? 'cloud_disabled' : 'cloud_not_configured'
84
+ throw error
85
+ }
80
86
  const resolved = await runtime.models.resolve(model, signal)
81
87
  const accessToken = await runtime.identity.access({ signal })
82
88
  return {
@@ -14,12 +14,12 @@ function savedServiceRecord(settings) {
14
14
  function candidateFrom(settings, env) {
15
15
  const saved = savedServiceRecord(settings)
16
16
  if (saved && typeof saved.cloudUrl === 'string' && saved.cloudUrl.trim()) {
17
- return { value: saved.cloudUrl, source: 'saved', saved: true }
17
+ return { value: saved.cloudUrl, source: 'saved', saved: true, enabled: saved.enabled === true }
18
18
  }
19
19
  if (typeof env?.QUICKFORGE_CLOUD_URL === 'string' && env.QUICKFORGE_CLOUD_URL.trim()) {
20
- return { value: env.QUICKFORGE_CLOUD_URL, source: 'env', saved: false }
20
+ return { value: env.QUICKFORGE_CLOUD_URL, source: 'env', saved: false, enabled: false }
21
21
  }
22
- return { value: DEFAULT_CLOUD_URL, source: 'default', saved: false }
22
+ return { value: DEFAULT_CLOUD_URL, source: 'default', saved: false, enabled: false }
23
23
  }
24
24
 
25
25
  export async function readCloudServiceConfig({ readSettings = readStore, env = process.env, strict = true } = {}) {
@@ -30,6 +30,7 @@ export async function readCloudServiceConfig({ readSettings = readStore, env = p
30
30
  return {
31
31
  schemaVersion: CLOUD_SERVICE_SCHEMA_VERSION,
32
32
  serviceType: CLOUD_SERVICE_TYPE,
33
+ enabled: candidate.enabled,
33
34
  cloudUrl: baseUrl.href,
34
35
  baseUrl,
35
36
  source: candidate.source,
@@ -41,6 +42,7 @@ export async function readCloudServiceConfig({ readSettings = readStore, env = p
41
42
  return {
42
43
  schemaVersion: CLOUD_SERVICE_SCHEMA_VERSION,
43
44
  serviceType: CLOUD_SERVICE_TYPE,
45
+ enabled: candidate.enabled,
44
46
  cloudUrl: String(candidate.value || '').trim(),
45
47
  source: candidate.source,
46
48
  saved: candidate.saved,
@@ -50,11 +52,12 @@ export async function readCloudServiceConfig({ readSettings = readStore, env = p
50
52
  }
51
53
  }
52
54
 
53
- export async function saveCloudServiceConfig(cloudUrl, { updateSettings = atomicUpdate } = {}) {
55
+ export async function saveCloudServiceConfig({ cloudUrl, enabled }, { updateSettings = atomicUpdate } = {}) {
54
56
  const baseUrl = parseCloudBaseUrl(cloudUrl)
55
57
  const record = {
56
58
  schemaVersion: CLOUD_SERVICE_SCHEMA_VERSION,
57
59
  serviceType: CLOUD_SERVICE_TYPE,
60
+ enabled: enabled === true,
58
61
  cloudUrl: baseUrl.href,
59
62
  }
60
63
  await updateSettings('settings', (settings) => ({
@@ -68,6 +71,7 @@ export function publicCloudServiceConfig(config) {
68
71
  return {
69
72
  schemaVersion: CLOUD_SERVICE_SCHEMA_VERSION,
70
73
  serviceType: CLOUD_SERVICE_TYPE,
74
+ enabled: config.enabled === true,
71
75
  cloudUrl: config.cloudUrl,
72
76
  source: config.source,
73
77
  saved: config.saved,
@@ -48,7 +48,7 @@ function localMessagesTokens(messages) {
48
48
  return normalizeMessagesForTokenEstimate(messages).reduce((total, message) => total + estimateTokens(message), 0)
49
49
  }
50
50
 
51
- export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
51
+ export function estimateContextUsage({ systemPrompt, messages, tools, model, minimumProviderUsageIndex = 0 }) {
52
52
  const contextWindow = Number(model?.contextWindow) || 0
53
53
  const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
54
54
  const normalizedMessages = normalizeMessagesForTokenEstimate(messages)
@@ -57,7 +57,12 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
57
57
  const toolsTokens = textTokens(safeJson(tools))
58
58
  const messagesTokens = localMessagesTokens(normalizedMessages)
59
59
  const estimatedInputTokens = systemPromptTokens + messagesTokens + toolsTokens
60
- const providerBasedContextTokens = Math.max(0, Number(coreEstimate.usageTokens) || 0) > 0
60
+ const normalizedMinimumProviderUsageIndex = Math.max(0, Number(minimumProviderUsageIndex) || 0)
61
+ const providerUsageIndex = Number.isInteger(coreEstimate.lastUsageIndex) ? coreEstimate.lastUsageIndex : -1
62
+ const providerUsageTokens = providerUsageIndex >= normalizedMinimumProviderUsageIndex
63
+ ? Math.max(0, Number(coreEstimate.usageTokens) || 0)
64
+ : 0
65
+ const providerBasedContextTokens = providerUsageTokens > 0
61
66
  ? Math.max(0, Number(coreEstimate.tokens) || 0)
62
67
  : 0
63
68
  const inputTokens = providerBasedContextTokens > 0
@@ -84,8 +89,10 @@ export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
84
89
  messagesTokens,
85
90
  toolsTokens,
86
91
  reservedOutputTokens,
87
- providerUsageTokens: Math.max(0, Number(coreEstimate.usageTokens) || 0),
88
- trailingTokens: Math.max(0, Number(coreEstimate.trailingTokens) || 0),
92
+ providerUsageTokens,
93
+ trailingTokens: providerUsageTokens > 0
94
+ ? Math.max(0, Number(coreEstimate.trailingTokens) || 0)
95
+ : messagesTokens,
89
96
  lastUsageIndex: coreEstimate.lastUsageIndex,
90
97
  localEstimatedContextTokens: estimatedInputTokens,
91
98
  },