@shawnstack/quickforge 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +12 -12
  2. package/bin/quickforge.mjs +9 -0
  3. package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
  4. package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
  5. package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
  6. package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
  7. package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
  8. package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
  9. package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
  10. package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
  11. package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
  12. package/dist/assets/icons-47L5YLKz.js +1 -0
  13. package/dist/assets/index-CqfScETb.js +1200 -0
  14. package/dist/assets/index-DzkBgHZf.css +3 -0
  15. package/dist/assets/{monaco-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-DunfCFfp.js} +1 -1
  17. package/dist/favicon.svg +16 -1
  18. package/dist/index.html +5 -5
  19. package/dist/manifest.webmanifest +30 -30
  20. package/package.json +3 -2
  21. package/server/acp/server.mjs +921 -0
  22. package/server/agent-manager.mjs +198 -32
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/auto-compaction.mjs +82 -39
  26. package/server/channels/process-channel.mjs +278 -0
  27. package/server/channels/providers/wechat.mjs +271 -0
  28. package/server/channels/registry.mjs +58 -0
  29. package/server/custom-commands.mjs +13 -1
  30. package/server/frontmatter.mjs +167 -0
  31. package/server/index.mjs +52 -3
  32. package/server/project-config.mjs +43 -6
  33. package/server/routes/agent-profiles.mjs +6 -2
  34. package/server/routes/agent.mjs +12 -1
  35. package/server/routes/channels.mjs +145 -0
  36. package/server/routes/models.mjs +68 -0
  37. package/server/routes/project.mjs +2 -2
  38. package/server/routes/scheduled-tasks.mjs +6 -5
  39. package/server/routes/storage.mjs +4 -2
  40. package/server/routes/system.mjs +27 -0
  41. package/server/routes/tools.mjs +17 -6
  42. package/server/routes/workspace.mjs +138 -0
  43. package/server/session-utils.mjs +10 -2
  44. package/server/storage.mjs +29 -2
  45. package/server/system-prompt.mjs +1 -0
  46. package/server/tools/definitions.mjs +18 -0
  47. package/server/tools/index.mjs +83 -0
  48. package/server/utils/package-update.mjs +156 -0
  49. package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
  50. package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
  51. package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
  52. package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
  53. package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
  54. package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
  55. package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
  56. package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
  57. package/dist/assets/icons-BWtivFsx.js +0 -1
  58. package/dist/assets/index-CxOHP41X.css +0 -3
  59. package/dist/assets/index-Dcf73EL8.js +0 -895
@@ -0,0 +1,921 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { Readable, Writable } from 'node:stream'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { ndJsonStream, AgentSideConnection, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
8
+ import {
9
+ createAgent,
10
+ runPrompt,
11
+ abortRun,
12
+ destroyAgent,
13
+ restoreAgent,
14
+ getSessionState,
15
+ getSessionEventBus,
16
+ listSessions as listAgentSessions,
17
+ approveToolCall,
18
+ rejectToolCall,
19
+ updateSessionModel,
20
+ updateSessionThinkingLevel,
21
+ } from '../agent-manager.mjs'
22
+ import { getActiveProject, getDefaultWorkspaceRoot, readProjectConfig, setActiveProjectPath, sameProjectPath } from '../project-config.mjs'
23
+ import { readSessionValue, readStore } from '../storage.mjs'
24
+ import { logger } from '../utils/logger.mjs'
25
+
26
+ const __filename = fileURLToPath(import.meta.url)
27
+ const __dirname = path.dirname(__filename)
28
+ const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json')
29
+
30
+ const APP_NAME = 'QuickForge'
31
+ const DEFAULT_MODE_ID = 'default'
32
+ const MODEL_CONFIG_ID = 'quickforge.model'
33
+ const THINKING_LEVEL_CONFIG_ID = 'quickforge.thinkingLevel'
34
+ const THINKING_LEVELS = [
35
+ { value: 'off', name: 'Off' },
36
+ { value: 'low', name: 'Low' },
37
+ { value: 'medium', name: 'Medium' },
38
+ { value: 'high', name: 'High' },
39
+ { value: 'xhigh', name: 'Extra High' },
40
+ ]
41
+ const EVENT_TEXT_LIMIT = 64 * 1024
42
+ const DOCUMENT_CONTEXT_LIMIT = 24 * 1024
43
+ const DOCUMENT_PREVIEW_LIMIT = 4 * 1024
44
+ const MAX_CONTEXT_DOCUMENTS = 4
45
+ const DANGEROUS_WORKSPACE_ROOTS = new Set([
46
+ path.parse(process.cwd()).root,
47
+ os.homedir(),
48
+ ].map((item) => path.resolve(item).toLowerCase()))
49
+
50
+ const pendingPrompts = new Map()
51
+ const pendingPermissions = new Set()
52
+ const acpSessions = new Map()
53
+ const acpDocuments = new Map()
54
+ let focusedDocumentUri = null
55
+
56
+ let packageInfoPromise = null
57
+
58
+ async function readPackageInfo() {
59
+ if (!packageInfoPromise) {
60
+ packageInfoPromise = fs.readFile(packageJsonPath, 'utf8')
61
+ .then((text) => JSON.parse(text))
62
+ .catch(() => ({ name: '@shawnstack/quickforge', version: '0.0.0' }))
63
+ }
64
+ return packageInfoPromise
65
+ }
66
+
67
+ function normalizePromptText(prompt = []) {
68
+ const parts = []
69
+ for (const block of Array.isArray(prompt) ? prompt : []) {
70
+ if (!block || typeof block !== 'object') continue
71
+ if (block.type === 'text' && typeof block.text === 'string') {
72
+ parts.push(block.text)
73
+ continue
74
+ }
75
+ if (block.type === 'resource_link') {
76
+ parts.push(`[resource: ${block.uri || block.name || 'unknown'}]`)
77
+ continue
78
+ }
79
+ if (block.type === 'resource' && block.resource?.text) {
80
+ parts.push(block.resource.text)
81
+ continue
82
+ }
83
+ parts.push(`[unsupported ${block.type || 'content'} content omitted]`)
84
+ }
85
+ return parts.join('\n\n').trim()
86
+ }
87
+
88
+ function textContent(text) {
89
+ return { type: 'text', text: String(text ?? '') }
90
+ }
91
+
92
+ function truncateText(value, limit = EVENT_TEXT_LIMIT) {
93
+ const text = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2)
94
+ if (text.length <= limit) return text
95
+ return `${text.slice(0, limit)}\n… truncated ${text.length - limit} characters`
96
+ }
97
+
98
+ function messageContentText(message) {
99
+ const content = message?.content
100
+ if (typeof content === 'string') return content
101
+ if (Array.isArray(content)) {
102
+ return content.map((part) => {
103
+ if (typeof part === 'string') return part
104
+ if (part?.type === 'text' && typeof part.text === 'string') return part.text
105
+ if (typeof part?.text === 'string') return part.text
106
+ return ''
107
+ }).filter(Boolean).join('\n')
108
+ }
109
+ return ''
110
+ }
111
+
112
+ function eventMessageText(event) {
113
+ return messageContentText(event?.message)
114
+ }
115
+
116
+ function documentUriFromParams(params = {}) {
117
+ return params.textDocument?.uri || params.document?.uri || params.uri || params.textDocumentUri || null
118
+ }
119
+
120
+ function documentTextFromParams(params = {}) {
121
+ if (typeof params.text === 'string') return params.text
122
+ if (typeof params.content === 'string') return params.content
123
+ if (typeof params.textDocument?.text === 'string') return params.textDocument.text
124
+ if (typeof params.document?.text === 'string') return params.document.text
125
+ const fullChange = Array.isArray(params.contentChanges)
126
+ ? params.contentChanges.find((change) => change && typeof change.text === 'string' && !change.range)
127
+ : null
128
+ return fullChange?.text ?? null
129
+ }
130
+
131
+ function documentLanguageFromParams(params = {}) {
132
+ return params.textDocument?.languageId || params.document?.languageId || params.languageId || ''
133
+ }
134
+
135
+ function documentVersionFromParams(params = {}) {
136
+ return params.textDocument?.version ?? params.document?.version ?? params.version ?? null
137
+ }
138
+
139
+ function updateAcpDocument(params = {}, reason = 'open') {
140
+ const uri = documentUriFromParams(params)
141
+ if (!uri) return
142
+ const previous = acpDocuments.get(uri) || { uri, text: '', languageId: '', version: null, updatedAt: new Date().toISOString() }
143
+ const text = documentTextFromParams(params)
144
+ const next = {
145
+ ...previous,
146
+ uri,
147
+ languageId: documentLanguageFromParams(params) || previous.languageId || '',
148
+ version: documentVersionFromParams(params) ?? previous.version ?? null,
149
+ text: typeof text === 'string' ? text : previous.text,
150
+ reason,
151
+ updatedAt: new Date().toISOString(),
152
+ }
153
+ acpDocuments.set(uri, next)
154
+ }
155
+
156
+ function closeAcpDocument(params = {}) {
157
+ const uri = documentUriFromParams(params)
158
+ if (!uri) return
159
+ acpDocuments.delete(uri)
160
+ if (focusedDocumentUri === uri) focusedDocumentUri = null
161
+ }
162
+
163
+ function focusAcpDocument(params = {}) {
164
+ const uri = documentUriFromParams(params)
165
+ if (!uri) return
166
+ focusedDocumentUri = uri
167
+ if (!acpDocuments.has(uri)) updateAcpDocument({ ...params, text: '' }, 'focus')
168
+ }
169
+
170
+ function formatDocumentContext(doc, limit) {
171
+ if (!doc) return ''
172
+ const header = [`URI: ${doc.uri}`]
173
+ if (doc.languageId) header.push(`Language: ${doc.languageId}`)
174
+ if (doc.version !== null && doc.version !== undefined) header.push(`Version: ${doc.version}`)
175
+ const text = truncateText(doc.text || '', limit)
176
+ return `${header.join('\n')}\nContent:\n${text}`
177
+ }
178
+
179
+ function sessionContext(sessionId) {
180
+ return acpSessions.get(sessionId) || null
181
+ }
182
+
183
+ function acpContextPrompt(sessionId) {
184
+ const parts = []
185
+ const session = sessionContext(sessionId)
186
+ if (session?.cwd) parts.push(`Workspace root: ${session.cwd}`)
187
+ if (Array.isArray(session?.additionalDirectories) && session.additionalDirectories.length > 0) {
188
+ parts.push(`Additional workspace roots:\n${session.additionalDirectories.map((dir) => `- ${dir}`).join('\n')}`)
189
+ }
190
+
191
+ const docs = []
192
+ if (focusedDocumentUri && acpDocuments.has(focusedDocumentUri)) docs.push(acpDocuments.get(focusedDocumentUri))
193
+ for (const doc of acpDocuments.values()) {
194
+ if (docs.length >= MAX_CONTEXT_DOCUMENTS) break
195
+ if (!docs.some((item) => item.uri === doc.uri)) docs.push(doc)
196
+ }
197
+ if (docs.length > 0) {
198
+ const renderedDocs = docs.map((doc, index) => {
199
+ const limit = index === 0 ? DOCUMENT_CONTEXT_LIMIT : DOCUMENT_PREVIEW_LIMIT
200
+ return `<document${doc.uri === focusedDocumentUri ? ' focused="true"' : ''}>\n${formatDocumentContext(doc, limit)}\n</document>`
201
+ }).join('\n\n')
202
+ parts.push(`Open editor documents:\n${renderedDocs}`)
203
+ }
204
+
205
+ if (parts.length === 0) return ''
206
+ return `<acp_context>\n${parts.join('\n\n')}\n</acp_context>`
207
+ }
208
+
209
+ function withAcpContext(sessionId, message) {
210
+ const context = acpContextPrompt(sessionId)
211
+ return context ? `${context}\n\n${message}` : message
212
+ }
213
+
214
+ function historyMessageUpdate(message, index) {
215
+ const role = message?.role
216
+ const text = messageContentText(message)
217
+ if (!text) return null
218
+ if (role === 'assistant') {
219
+ return {
220
+ sessionUpdate: 'agent_message_chunk',
221
+ content: textContent(text),
222
+ messageId: `history-assistant-${index}`,
223
+ }
224
+ }
225
+ if (role === 'user' || role === 'user-with-attachments') {
226
+ return {
227
+ sessionUpdate: 'user_message_chunk',
228
+ content: textContent(text),
229
+ messageId: `history-user-${index}`,
230
+ }
231
+ }
232
+ return null
233
+ }
234
+
235
+ export function convertMessagesToHistoryUpdates(messages = []) {
236
+ return (Array.isArray(messages) ? messages : [])
237
+ .map((message, index) => historyMessageUpdate(message, index))
238
+ .filter(Boolean)
239
+ }
240
+
241
+ async function replaySessionHistory(sessionId, conn) {
242
+ if (!conn?.sessionUpdate) return
243
+ const state = getSessionState(sessionId)
244
+ const messages = state?.messages || (await readSessionValue(sessionId))?.messages || []
245
+ for (const update of convertMessagesToHistoryUpdates(messages)) {
246
+ await conn.sessionUpdate({ sessionId, update })
247
+ }
248
+ }
249
+
250
+ function toolKind(toolName = '') {
251
+ if (/read|list|cat|show/i.test(toolName)) return 'read'
252
+ if (/write|edit|patch|present/i.test(toolName)) return 'edit'
253
+ if (/grep|search|find/i.test(toolName)) return 'search'
254
+ if (/run|command|terminal|subagent/i.test(toolName)) return 'execute'
255
+ if (/fetch|http|web/i.test(toolName)) return 'fetch'
256
+ return 'other'
257
+ }
258
+
259
+ function toolTitle(event) {
260
+ const name = event?.toolName || event?.name || 'tool'
261
+ return event?.label || `Run ${name}`
262
+ }
263
+
264
+ function toolCallIdFromEvent(event) {
265
+ return String(event?.toolCallId || event?.id || '')
266
+ }
267
+
268
+ function toolInput(event) {
269
+ if (event?.args !== undefined) return event.args
270
+ if (event?.input !== undefined) return event.input
271
+ if (event?.params !== undefined) return event.params
272
+ return undefined
273
+ }
274
+
275
+ function toolOutput(event) {
276
+ if (event?.result !== undefined) return event.result
277
+ if (event?.output !== undefined) return event.output
278
+ if (event?.error !== undefined) return { error: event.error }
279
+ return undefined
280
+ }
281
+
282
+ function toolContentFromOutput(output) {
283
+ if (output === undefined) return undefined
284
+ return [{ type: 'content', content: textContent(truncateText(output)) }]
285
+ }
286
+
287
+ function permissionKey(sessionId, toolCallId) {
288
+ return `${sessionId}:${toolCallId}`
289
+ }
290
+
291
+ async function requestToolPermission(sessionId, event, conn) {
292
+ const toolCallId = toolCallIdFromEvent(event)
293
+ if (!toolCallId) return
294
+ const key = permissionKey(sessionId, toolCallId)
295
+ if (pendingPermissions.has(key)) return
296
+ pendingPermissions.add(key)
297
+ try {
298
+ const response = await conn.requestPermission({
299
+ sessionId,
300
+ toolCall: {
301
+ toolCallId,
302
+ kind: toolKind(event.toolName),
303
+ status: 'pending',
304
+ title: toolTitle(event),
305
+ rawInput: event.args,
306
+ },
307
+ options: [
308
+ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
309
+ { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' },
310
+ ],
311
+ })
312
+ const selected = response?.outcome?.outcome === 'selected' ? response.outcome.optionId : null
313
+ if (selected === 'allow_once') {
314
+ approveToolCall(sessionId, toolCallId)
315
+ } else {
316
+ rejectToolCall(sessionId, toolCallId)
317
+ }
318
+ } finally {
319
+ pendingPermissions.delete(key)
320
+ }
321
+ }
322
+
323
+ export function convertEventToUpdates(event, state = {}) {
324
+ if (!event || typeof event !== 'object') return []
325
+ const updates = []
326
+
327
+ if (event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end') {
328
+ const text = eventMessageText(event)
329
+ const messageId = String(event.message?.id || event.messageId || 'assistant')
330
+ const previous = state.messageTextById?.get(messageId) || ''
331
+ const chunk = text.startsWith(previous) ? text.slice(previous.length) : text
332
+ if (chunk && event.message?.role === 'assistant') {
333
+ state.messageTextById?.set(messageId, text)
334
+ updates.push({
335
+ sessionUpdate: 'agent_message_chunk',
336
+ content: textContent(chunk),
337
+ messageId,
338
+ })
339
+ }
340
+ }
341
+
342
+ if (event.type === 'tool_execution_start') {
343
+ const id = toolCallIdFromEvent(event)
344
+ if (id) {
345
+ updates.push({
346
+ sessionUpdate: 'tool_call',
347
+ toolCallId: id,
348
+ title: toolTitle(event),
349
+ kind: toolKind(event.toolName),
350
+ status: 'in_progress',
351
+ rawInput: toolInput(event),
352
+ })
353
+ }
354
+ }
355
+
356
+ if (event.type === 'tool_execution_update') {
357
+ const id = toolCallIdFromEvent(event)
358
+ if (id) {
359
+ updates.push({
360
+ sessionUpdate: 'tool_call_update',
361
+ toolCallId: id,
362
+ status: 'in_progress',
363
+ rawOutput: toolOutput(event),
364
+ content: toolContentFromOutput(toolOutput(event)),
365
+ })
366
+ }
367
+ }
368
+
369
+ if (event.type === 'tool_execution_end') {
370
+ const id = toolCallIdFromEvent(event)
371
+ if (id) {
372
+ const output = toolOutput(event)
373
+ updates.push({
374
+ sessionUpdate: 'tool_call_update',
375
+ toolCallId: id,
376
+ status: event.error ? 'failed' : 'completed',
377
+ rawOutput: output,
378
+ content: toolContentFromOutput(output),
379
+ })
380
+ }
381
+ }
382
+
383
+ if (event.type === 'tool_approval_required') {
384
+ const id = toolCallIdFromEvent(event)
385
+ if (id) {
386
+ updates.push({
387
+ sessionUpdate: 'tool_call',
388
+ toolCallId: id,
389
+ title: toolTitle(event),
390
+ kind: toolKind(event.toolName),
391
+ status: 'pending',
392
+ rawInput: event.args,
393
+ })
394
+ }
395
+ }
396
+
397
+ if (event.type === 'title_updated' && typeof event.title === 'string') {
398
+ updates.push({
399
+ sessionUpdate: 'session_info_update',
400
+ title: event.title,
401
+ updatedAt: new Date().toISOString(),
402
+ })
403
+ }
404
+
405
+ if (event.contextUsage?.used && event.contextUsage?.size) {
406
+ updates.push({
407
+ sessionUpdate: 'usage_update',
408
+ used: event.contextUsage.used,
409
+ size: event.contextUsage.size,
410
+ })
411
+ }
412
+
413
+ return updates
414
+ }
415
+
416
+ function sessionModes() {
417
+ return {
418
+ currentModeId: DEFAULT_MODE_ID,
419
+ availableModes: [{
420
+ id: DEFAULT_MODE_ID,
421
+ name: 'Default',
422
+ description: 'QuickForge default agent mode',
423
+ }],
424
+ }
425
+ }
426
+
427
+ function parseStoredJson(value) {
428
+ if (typeof value !== 'string') return value
429
+ try { return JSON.parse(value) } catch { return null }
430
+ }
431
+
432
+ function isUsableModel(model) {
433
+ return Boolean(model?.id && model?.provider && model?.api && model?.baseUrl)
434
+ }
435
+
436
+ function sameBaseUrl(a, b) {
437
+ return String(a || '').trim().replace(/\/$/, '') === String(b || '').trim().replace(/\/$/, '')
438
+ }
439
+
440
+ function sameModel(a, b) {
441
+ return Boolean(a && b && a.id === b.id && a.provider === b.provider && a.api === b.api && sameBaseUrl(a.baseUrl, b.baseUrl))
442
+ }
443
+
444
+ function modelValueId(model) {
445
+ const payload = JSON.stringify({
446
+ id: model.id,
447
+ provider: model.provider,
448
+ api: model.api,
449
+ baseUrl: model.baseUrl,
450
+ })
451
+ return Buffer.from(payload, 'utf8').toString('base64url')
452
+ }
453
+
454
+ function modelDisplayName(model) {
455
+ return model.name || `${model.provider}/${model.id}`
456
+ }
457
+
458
+ function isThinkingLevel(value) {
459
+ return THINKING_LEVELS.some((level) => level.value === value)
460
+ }
461
+
462
+ function thinkingLevelConfigOption(currentModel = null, currentThinkingLevel = 'off') {
463
+ const supportsThinking = currentModel?.reasoning === true
464
+ const options = supportsThinking ? THINKING_LEVELS : THINKING_LEVELS.slice(0, 1)
465
+ const currentValue = supportsThinking && isThinkingLevel(currentThinkingLevel) ? currentThinkingLevel : 'off'
466
+ return {
467
+ id: THINKING_LEVEL_CONFIG_ID,
468
+ name: 'Thinking Level',
469
+ description: supportsThinking
470
+ ? 'Select the reasoning/thinking level for this ACP session.'
471
+ : 'The selected model does not support reasoning, so thinking is disabled.',
472
+ category: 'thought_level',
473
+ type: 'select',
474
+ currentValue,
475
+ options,
476
+ }
477
+ }
478
+
479
+ async function readConfiguredModels() {
480
+ const store = await readStore('custom-providers').catch(() => [])
481
+ const providers = Array.isArray(store) ? store : Object.values(store || {})
482
+ return providers
483
+ .flatMap((provider) => Array.isArray(provider?.models) ? provider.models : [])
484
+ .filter(isUsableModel)
485
+ }
486
+
487
+ async function readActiveModel() {
488
+ const settings = await readStore('settings').catch(() => ({}))
489
+ const model = parseStoredJson(settings?.['active-model'])
490
+ return isUsableModel(model) ? model : null
491
+ }
492
+
493
+ async function resolveInitialModel() {
494
+ const [configuredModels, activeModel] = await Promise.all([readConfiguredModels(), readActiveModel()])
495
+ if (activeModel) {
496
+ return configuredModels.find((model) => sameModel(model, activeModel)) || activeModel
497
+ }
498
+ return configuredModels[0] || null
499
+ }
500
+
501
+ // Resolve the initial thinking level for a new ACP session, mirroring the web UI
502
+ // (src/hooks/useAgentManager.ts): prefer the user's saved default thinking level,
503
+ // otherwise fall back to 'medium' for reasoning models and 'off' otherwise.
504
+ async function resolveInitialThinkingLevel(model) {
505
+ const settings = await readStore('settings').catch(() => ({}))
506
+ const defaultOptions = parseStoredJson(settings?.['default-options'])
507
+ const saved = defaultOptions?.thinkingLevel
508
+ if (isThinkingLevel(saved)) return saved
509
+ return model?.reasoning === true ? 'medium' : 'off'
510
+ }
511
+
512
+ async function sessionConfigOptions(currentModel = null, currentThinkingLevel = 'off') {
513
+ const configuredModels = await readConfiguredModels()
514
+ const models = [...configuredModels]
515
+ const options = []
516
+ if (currentModel && !models.some((model) => sameModel(model, currentModel))) models.unshift(currentModel)
517
+
518
+ if (models.length > 0) {
519
+ const selectedModel = currentModel || models[0]
520
+ const groups = []
521
+ for (const model of models) {
522
+ const groupId = model.provider || 'custom'
523
+ let group = groups.find((item) => item.group === groupId)
524
+ if (!group) {
525
+ group = { group: groupId, name: model.provider || 'Custom', options: [] }
526
+ groups.push(group)
527
+ }
528
+ group.options.push({
529
+ value: modelValueId(model),
530
+ name: modelDisplayName(model),
531
+ description: `${model.provider} · ${model.api} · ${model.baseUrl}`,
532
+ _meta: { quickforgeModel: model },
533
+ })
534
+ }
535
+
536
+ options.push({
537
+ id: MODEL_CONFIG_ID,
538
+ name: 'Model',
539
+ description: 'Select the QuickForge model used by this ACP session.',
540
+ category: 'model',
541
+ type: 'select',
542
+ currentValue: modelValueId(selectedModel),
543
+ options: groups,
544
+ })
545
+ }
546
+
547
+ options.push(thinkingLevelConfigOption(currentModel, currentThinkingLevel))
548
+ return options
549
+ }
550
+
551
+ async function sessionConfigOptionsForSession(sessionId) {
552
+ const state = getSessionState(sessionId)
553
+ if (!state) throw new Error('Session not found')
554
+ return sessionConfigOptions(state.model, state.thinkingLevel)
555
+ }
556
+
557
+ async function selectSessionModel(sessionId, value) {
558
+ const state = getSessionState(sessionId)
559
+ if (!state) throw new Error('Session not found')
560
+ const models = await readConfiguredModels()
561
+ if (state.model && !models.some((model) => sameModel(model, state.model))) models.unshift(state.model)
562
+ const model = models.find((candidate) => modelValueId(candidate) === value)
563
+ if (!model) throw new Error('Selected model is not configured in QuickForge.')
564
+ updateSessionModel(sessionId, model)
565
+ const thinkingLevel = model.reasoning === true ? state.thinkingLevel : 'off'
566
+ if (thinkingLevel !== state.thinkingLevel) updateSessionThinkingLevel(sessionId, thinkingLevel)
567
+ return { configOptions: await sessionConfigOptions(model, thinkingLevel) }
568
+ }
569
+
570
+ async function selectSessionThinkingLevel(sessionId, value) {
571
+ const state = getSessionState(sessionId)
572
+ if (!state) throw new Error('Session not found')
573
+ if (!isThinkingLevel(value)) throw new Error(`Unknown thinking level: ${value}`)
574
+ if (value !== 'off' && state.model?.reasoning !== true) throw new Error('The selected model does not support reasoning.')
575
+ updateSessionThinkingLevel(sessionId, value)
576
+ return { configOptions: await sessionConfigOptions(state.model, value) }
577
+ }
578
+
579
+ async function assertSafeAcpCwd(cwd) {
580
+ if (typeof cwd !== 'string' || !cwd.trim()) {
581
+ throw new Error('ACP session cwd is required.')
582
+ }
583
+ if (!path.isAbsolute(cwd)) {
584
+ throw new Error('ACP session cwd must be an absolute path.')
585
+ }
586
+
587
+ const resolved = path.resolve(cwd)
588
+ const stat = await fs.stat(resolved).catch(() => null)
589
+ if (!stat?.isDirectory()) {
590
+ throw new Error(`ACP session cwd is not a directory: ${resolved}`)
591
+ }
592
+
593
+ const real = await fs.realpath(resolved)
594
+ const normalized = path.resolve(real).toLowerCase()
595
+ if (DANGEROUS_WORKSPACE_ROOTS.has(normalized)) {
596
+ throw new Error(`Refusing to use unsafe ACP workspace root: ${real}`)
597
+ }
598
+ return real
599
+ }
600
+
601
+ async function assertSafeAcpAdditionalDirectories(additionalDirectories = []) {
602
+ if (!Array.isArray(additionalDirectories)) return []
603
+ const result = []
604
+ const seen = new Set()
605
+ for (const dir of additionalDirectories) {
606
+ const safeDir = await assertSafeAcpCwd(dir)
607
+ const key = safeDir.toLowerCase()
608
+ if (!seen.has(key)) {
609
+ seen.add(key)
610
+ result.push(safeDir)
611
+ }
612
+ }
613
+ return result
614
+ }
615
+
616
+ function isDefaultWorkspaceCwd(cwd) {
617
+ const defaultWorkspaceRoot = getDefaultWorkspaceRoot()
618
+ return defaultWorkspaceRoot && sameProjectPath(cwd, defaultWorkspaceRoot)
619
+ }
620
+
621
+ async function resolveProjectForCwd(cwd) {
622
+ const resolvedCwd = await assertSafeAcpCwd(cwd)
623
+ if (isDefaultWorkspaceCwd(resolvedCwd)) return null
624
+ const config = await readProjectConfig()
625
+ let project = config.projects.find((item) => sameProjectPath(item.path, resolvedCwd))
626
+ if (!project) {
627
+ const updated = await setActiveProjectPath(resolvedCwd)
628
+ project = updated.project
629
+ }
630
+ return project || getActiveProject(config)
631
+ }
632
+
633
+ async function createQuickForgeSession(params = {}) {
634
+ const cwd = await assertSafeAcpCwd(params.cwd)
635
+ const additionalDirectories = await assertSafeAcpAdditionalDirectories(params.additionalDirectories)
636
+ const project = await resolveProjectForCwd(cwd)
637
+ const sessionId = params._meta?.quickforgeSessionId || randomUUID()
638
+ const model = await resolveInitialModel()
639
+ const thinkingLevel = await resolveInitialThinkingLevel(model)
640
+ await createAgent(sessionId, {
641
+ scope: project?.id ? 'project' : 'global',
642
+ projectId: project?.id || null,
643
+ accessMode: 'default',
644
+ yoloMode: false,
645
+ model,
646
+ thinkingLevel,
647
+ title: 'ACP session',
648
+ idleRetention: 'always',
649
+ })
650
+ acpSessions.set(sessionId, { cwd, additionalDirectories, projectId: project?.id || null })
651
+ return { sessionId, modes: sessionModes(), configOptions: await sessionConfigOptions(model, thinkingLevel) }
652
+ }
653
+
654
+ function waitForPromptEnd(sessionId, conn, signal) {
655
+ if (pendingPrompts.has(sessionId)) return Promise.reject(new Error('ACP prompt is already running for this session.'))
656
+ const eventBus = getSessionEventBus(sessionId)
657
+ if (!eventBus) return Promise.reject(new Error('Session not found'))
658
+
659
+ return new Promise((resolve, reject) => {
660
+ let settled = false
661
+
662
+ const cleanup = () => {
663
+ pendingPrompts.delete(sessionId)
664
+ eventBus.off('agent_event', onEvent)
665
+ signal?.removeEventListener?.('abort', onAbort)
666
+ }
667
+
668
+ const finish = (result) => {
669
+ if (settled) return
670
+ settled = true
671
+ cleanup()
672
+ resolve(result)
673
+ }
674
+
675
+ const fail = (err) => {
676
+ if (settled) return
677
+ settled = true
678
+ cleanup()
679
+ reject(err)
680
+ }
681
+
682
+ const sendUpdate = (update) => {
683
+ conn.sessionUpdate({ sessionId, update }).catch((err) => {
684
+ logger.warn(`ACP session/update failed for ${sessionId}: ${err?.message || err}`, { sessionId })
685
+ })
686
+ }
687
+
688
+ const conversionState = { messageTextById: new Map() }
689
+
690
+ const onEvent = (event) => {
691
+ for (const update of convertEventToUpdates(event, conversionState)) sendUpdate(update)
692
+
693
+ if (event?.type === 'tool_approval_required') {
694
+ requestToolPermission(sessionId, event, conn).catch((err) => {
695
+ logger.warn(`ACP tool permission failed for ${sessionId}: ${err?.message || err}`, { sessionId })
696
+ try { rejectToolCall(sessionId, event.toolCallId) } catch { /* ignore */ }
697
+ })
698
+ }
699
+
700
+ if (event?.type === 'error') {
701
+ fail(new Error(event.error || 'QuickForge agent error'))
702
+ return
703
+ }
704
+
705
+ if (event?.type === 'agent_end') {
706
+ finish({ stopReason: event.status === 'aborted' ? 'cancelled' : 'end_turn' })
707
+ }
708
+ }
709
+
710
+ const onAbort = () => {
711
+ abortRun(sessionId).catch(() => {})
712
+ finish({ stopReason: 'cancelled' })
713
+ }
714
+
715
+ pendingPrompts.set(sessionId, { cancel: onAbort })
716
+
717
+ eventBus.on('agent_event', onEvent)
718
+ if (signal) {
719
+ if (signal.aborted) {
720
+ onAbort()
721
+ return
722
+ }
723
+ signal.addEventListener('abort', onAbort, { once: true })
724
+ }
725
+ })
726
+ }
727
+
728
+ async function listPersistedAcpSessions(params = {}) {
729
+ const [metadata, projectConfig] = await Promise.all([
730
+ readStore('sessions-metadata').catch(() => ({})),
731
+ readProjectConfig().catch(() => ({ projects: [] })),
732
+ ])
733
+ const projectPathById = new Map((projectConfig.projects || []).map((project) => [project.id, path.resolve(project.path)]))
734
+ const requestedCwd = params.cwd ? path.resolve(params.cwd) : null
735
+ const sessionsById = new Map()
736
+
737
+ for (const meta of Object.values(metadata || {})) {
738
+ if (!meta?.id) continue
739
+ const cwd = meta.scope === 'project' && meta.projectId && projectPathById.has(meta.projectId)
740
+ ? projectPathById.get(meta.projectId)
741
+ : process.cwd()
742
+ if (requestedCwd && path.resolve(cwd) !== requestedCwd) continue
743
+ sessionsById.set(meta.id, {
744
+ sessionId: meta.id,
745
+ cwd,
746
+ additionalDirectories: [],
747
+ title: meta.title || 'ACP session',
748
+ updatedAt: meta.lastModified || meta.createdAt || new Date().toISOString(),
749
+ })
750
+ }
751
+
752
+ for (const session of listAgentSessions()) {
753
+ const context = acpSessions.get(session.sessionId)
754
+ const cwd = context?.cwd || process.cwd()
755
+ if (requestedCwd && path.resolve(cwd) !== requestedCwd) continue
756
+ sessionsById.set(session.sessionId, {
757
+ sessionId: session.sessionId,
758
+ cwd,
759
+ additionalDirectories: context?.additionalDirectories || [],
760
+ title: session.title || sessionsById.get(session.sessionId)?.title || 'ACP session',
761
+ updatedAt: new Date().toISOString(),
762
+ })
763
+ }
764
+
765
+ return [...sessionsById.values()].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
766
+ }
767
+
768
+ export async function createQuickForgeAcpAgent() {
769
+ const pkg = await readPackageInfo()
770
+ return {
771
+ async initialize(params = {}) {
772
+ return {
773
+ protocolVersion: Math.min(params.protocolVersion || PROTOCOL_VERSION, PROTOCOL_VERSION),
774
+ agentCapabilities: {
775
+ promptCapabilities: {
776
+ embeddedContext: true,
777
+ },
778
+ sessionCapabilities: {
779
+ list: {},
780
+ delete: {},
781
+ close: {},
782
+ additionalDirectories: {},
783
+ },
784
+ nes: {
785
+ events: {
786
+ document: {
787
+ didOpen: {},
788
+ didChange: { syncKind: 'full' },
789
+ didClose: {},
790
+ didSave: {},
791
+ didFocus: {},
792
+ },
793
+ },
794
+ },
795
+ },
796
+ authMethods: [],
797
+ agentInfo: {
798
+ name: APP_NAME,
799
+ version: pkg.version || '0.0.0',
800
+ },
801
+ }
802
+ },
803
+
804
+ async newSession(params = {}) {
805
+ return createQuickForgeSession(params)
806
+ },
807
+
808
+ async loadSession(params = {}, conn = null) {
809
+ const additionalDirectories = await assertSafeAcpAdditionalDirectories(params.additionalDirectories)
810
+ const session = await restoreAgent(params.sessionId)
811
+ if (!session) throw new Error('Session not found')
812
+ const projectConfig = await readProjectConfig().catch(() => ({ projects: [] }))
813
+ const project = session.projectId ? projectConfig.projects.find((item) => item.id === session.projectId) : null
814
+ acpSessions.set(params.sessionId, {
815
+ cwd: params.cwd ? await assertSafeAcpCwd(params.cwd) : path.resolve(project?.path || process.cwd()),
816
+ additionalDirectories,
817
+ projectId: session.projectId || null,
818
+ })
819
+ await replaySessionHistory(params.sessionId, conn)
820
+ return { modes: sessionModes(), configOptions: await sessionConfigOptionsForSession(params.sessionId) }
821
+ },
822
+
823
+ async listSessions(params = {}) {
824
+ return { sessions: await listPersistedAcpSessions(params) }
825
+ },
826
+
827
+ async deleteSession(params = {}) {
828
+ await destroyAgent(params.sessionId)
829
+ acpSessions.delete(params.sessionId)
830
+ return {}
831
+ },
832
+
833
+ async closeSession(params = {}) {
834
+ try {
835
+ await abortRun(params.sessionId)
836
+ } catch {
837
+ // ignore idle or missing active run during close
838
+ }
839
+ await destroyAgent(params.sessionId)
840
+ acpSessions.delete(params.sessionId)
841
+ return {}
842
+ },
843
+
844
+ async setSessionConfigOption(params = {}) {
845
+ if (params.type === 'boolean') throw new Error('QuickForge ACP config options require select values.')
846
+ if (params.configId === MODEL_CONFIG_ID) return selectSessionModel(params.sessionId, params.value)
847
+ if (params.configId === THINKING_LEVEL_CONFIG_ID) return selectSessionThinkingLevel(params.sessionId, params.value)
848
+ throw new Error(`Unknown ACP config option: ${params.configId}`)
849
+ },
850
+
851
+ async prompt(params = {}, conn, signal) {
852
+ const message = normalizePromptText(params.prompt)
853
+ if (!message) throw new Error('Prompt is empty or unsupported.')
854
+ const state = getSessionState(params.sessionId)
855
+ if (!state) throw new Error('Session not found')
856
+ const done = waitForPromptEnd(params.sessionId, conn, signal)
857
+ await runPrompt(params.sessionId, withAcpContext(params.sessionId, message))
858
+ return done
859
+ },
860
+
861
+ async cancel(params = {}) {
862
+ const pendingPrompt = pendingPrompts.get(params.sessionId)
863
+ if (pendingPrompt) {
864
+ pendingPrompt.cancel()
865
+ return
866
+ }
867
+ await abortRun(params.sessionId)
868
+ },
869
+
870
+ async didOpenDocument(params = {}) {
871
+ updateAcpDocument(params, 'open')
872
+ },
873
+
874
+ async didChangeDocument(params = {}) {
875
+ updateAcpDocument(params, 'change')
876
+ },
877
+
878
+ async didSaveDocument(params = {}) {
879
+ updateAcpDocument(params, 'save')
880
+ },
881
+
882
+ async didCloseDocument(params = {}) {
883
+ closeAcpDocument(params)
884
+ },
885
+
886
+ async didFocusDocument(params = {}) {
887
+ focusAcpDocument(params)
888
+ },
889
+ }
890
+ }
891
+
892
+ export async function runQuickForgeAcpStdio() {
893
+ const originalConsoleLog = console.log
894
+ console.log = (...args) => console.error(...args)
895
+ try {
896
+ const quickForgeAgent = await createQuickForgeAcpAgent()
897
+ const stream = ndJsonStream(
898
+ Writable.toWeb(process.stdout),
899
+ Readable.toWeb(process.stdin),
900
+ )
901
+ const connection = new AgentSideConnection((conn) => ({
902
+ initialize: (params) => quickForgeAgent.initialize(params),
903
+ newSession: (params) => quickForgeAgent.newSession(params),
904
+ loadSession: (params) => quickForgeAgent.loadSession(params, conn),
905
+ listSessions: (params) => quickForgeAgent.listSessions(params),
906
+ deleteSession: (params) => quickForgeAgent.deleteSession(params),
907
+ closeSession: (params) => quickForgeAgent.closeSession(params),
908
+ setSessionConfigOption: (params) => quickForgeAgent.setSessionConfigOption(params),
909
+ prompt: (params) => quickForgeAgent.prompt(params, conn, connection.signal),
910
+ cancel: (params) => quickForgeAgent.cancel(params),
911
+ unstable_didOpenDocument: (params) => quickForgeAgent.didOpenDocument(params),
912
+ unstable_didChangeDocument: (params) => quickForgeAgent.didChangeDocument(params),
913
+ unstable_didSaveDocument: (params) => quickForgeAgent.didSaveDocument(params),
914
+ unstable_didCloseDocument: (params) => quickForgeAgent.didCloseDocument(params),
915
+ unstable_didFocusDocument: (params) => quickForgeAgent.didFocusDocument(params),
916
+ }), stream)
917
+ await connection.closed
918
+ } finally {
919
+ console.log = originalConsoleLog
920
+ }
921
+ }