@shawnstack/quickforge 1.7.5 → 1.7.6

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 (66) hide show
  1. package/dist/assets/AgentProfilesPage-Dl5Peqov.js +1 -0
  2. package/dist/assets/ChatPanelHost-D3987TUJ.js +307 -0
  3. package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +1 -0
  4. package/dist/assets/PluginsPage-BDKYfLna.js +1 -0
  5. package/dist/assets/ScheduledTasksPage--GAki3lB.js +2 -0
  6. package/dist/assets/SettingsWorkspacePage-DSy66Tuw.js +1696 -0
  7. package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +1 -0
  8. package/dist/assets/SharedConversationPage-B9BUVCri.js +1 -0
  9. package/dist/assets/TerminalDock-CT1nYo7P.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-D9kdnLDd.js → WorkspaceInspector-BnfDLf9a.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-1ei6nhs-.css +3 -0
  15. package/dist/assets/index-BivLuZvX.js +65 -0
  16. package/dist/assets/{mcp-servers-dialog-BUSPvaqI.js → mcp-servers-dialog-BEV4_TXC.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-BTJ7mzk_.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 +35 -8
  34. package/server/cloud/client.mjs +4 -7
  35. package/server/cloud/config.mjs +6 -5
  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/index.mjs +96 -37
  43. package/server/mcp/registry.mjs +8 -7
  44. package/server/message-converters.mjs +40 -19
  45. package/server/model-catalog.mjs +11 -4
  46. package/server/opencode-acp-agent.mjs +1151 -0
  47. package/server/public-api.mjs +11 -5
  48. package/server/routes/agent-profiles.mjs +6 -3
  49. package/server/routes/agent.mjs +43 -3
  50. package/server/routes/backup.mjs +5 -4
  51. package/server/routes/cloud.mjs +54 -35
  52. package/server/routes/shares.mjs +7 -8
  53. package/server/routes/storage.mjs +9 -24
  54. package/server/routes/system.mjs +7 -4
  55. package/server/utils/process-tree.mjs +78 -0
  56. package/dist/assets/AgentProfilesPage-XNjuqJs7.js +0 -1
  57. package/dist/assets/ChatPanelHost-CuF-PZpc.js +0 -292
  58. package/dist/assets/CloudAccountSettingsPage-GJSoi6sO.js +0 -1
  59. package/dist/assets/PluginsPage-BQgp0qN-.js +0 -1
  60. package/dist/assets/ScheduledTasksPage-DdekATzq.js +0 -2
  61. package/dist/assets/SettingsWorkspacePage-Cb54Ic91.js +0 -1639
  62. package/dist/assets/ShareLinksSettingsPage-C75WJpaF.js +0 -1
  63. package/dist/assets/SharedConversationPage-CjMtSQTW.js +0 -1
  64. package/dist/assets/TerminalDock-D4zJBJc4.js +0 -2
  65. package/dist/assets/index-_ZhK6bvp.js +0 -65
  66. package/dist/assets/index-iVV2bP_f.css +0 -3
@@ -0,0 +1,1151 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { Readable, Writable } from 'node:stream'
5
+ import { client, methods, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
6
+ import { terminateProcessTree } from './utils/process-tree.mjs'
7
+
8
+ const OPEN_CODE_COMMAND = process.platform === 'win32' ? 'opencode.cmd' : 'opencode'
9
+ const ACP_CLIENT_NAME = 'QuickForge'
10
+ const DIAGNOSTIC_LIMIT = 2048
11
+ const TOOL_OUTPUT_LIMIT = 16 * 1024
12
+ const TOOL_METADATA_LOCATION_LIMIT = 20
13
+ const SETUP_METADATA_BUFFER_LIMIT = 100
14
+ const ACP_TOOL_KIND_NAMES = Object.freeze({
15
+ read: 'read_file',
16
+ edit: 'edit_file',
17
+ search: 'grep_files',
18
+ execute: 'run_command',
19
+ })
20
+ const OPENCODE_RENDERER_TOOL_NAMES = new Set([
21
+ 'read_file',
22
+ 'edit_file',
23
+ 'grep_files',
24
+ 'run_command',
25
+ 'opencode_tool',
26
+ ])
27
+ const ACP_METADATA_UPDATE_TYPES = new Set([
28
+ 'available_commands_update',
29
+ 'current_mode_update',
30
+ 'config_option_update',
31
+ 'session_info_update',
32
+ 'usage_update',
33
+ ])
34
+
35
+ export const DEFAULT_OPENCODE_ACP_TIMEOUTS = Object.freeze({
36
+ initialize: 15_000,
37
+ sessionSetup: 30_000,
38
+ prompt: 60 * 60_000,
39
+ close: 2_000,
40
+ })
41
+
42
+ function pathEntries() {
43
+ return String(process.env.PATH || '').split(path.delimiter).filter(Boolean)
44
+ }
45
+
46
+ export async function resolveOpenCodeCommand() {
47
+ for (const directory of pathEntries()) {
48
+ const candidate = path.join(directory, OPEN_CODE_COMMAND)
49
+ try {
50
+ const stat = await fs.stat(candidate)
51
+ if (stat.isFile()) return candidate
52
+ } catch {
53
+ // Continue searching PATH.
54
+ }
55
+ }
56
+ throw Object.assign(new Error('OpenCode is unavailable. Install OpenCode and ensure the opencode command is on PATH.'), {
57
+ statusCode: 503,
58
+ errorCode: 'OPENCODE_UNAVAILABLE',
59
+ })
60
+ }
61
+
62
+ function quoteWindowsCommandArg(value) {
63
+ const text = String(value).replace(/%/g, '%%')
64
+ if (/^[A-Za-z0-9_./:\\-]+$/.test(text)) return text
65
+ return `"${text.replace(/"/g, '\\"')}"`
66
+ }
67
+
68
+ function spawnOpenCode(command, cwd, spawnImpl = spawn, platform = process.platform) {
69
+ const args = ['acp', '--pure', '--cwd', cwd]
70
+ if (platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(command)) {
71
+ return spawnImpl(command, args, {
72
+ cwd,
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ windowsHide: true,
75
+ ...(platform === 'win32' ? {} : { detached: true }),
76
+ })
77
+ }
78
+
79
+ const commandLine = [command, ...args].map(quoteWindowsCommandArg).join(' ')
80
+ return spawnImpl(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], {
81
+ cwd,
82
+ stdio: ['pipe', 'pipe', 'pipe'],
83
+ windowsHide: true,
84
+ })
85
+ }
86
+
87
+ function messageText(message) {
88
+ if (typeof message === 'string') return message
89
+ const content = message?.content
90
+ if (typeof content === 'string') return content
91
+ if (!Array.isArray(content)) return ''
92
+ return content.map((part) => typeof part === 'string' ? part : part?.type === 'text' ? part.text || '' : '').filter(Boolean).join('\n')
93
+ }
94
+
95
+ function attachmentError(message, errorCode = 'OPENCODE_ATTACHMENT_INVALID') {
96
+ return Object.assign(new Error(message), { statusCode: 400, errorCode })
97
+ }
98
+
99
+ function isBase64(value) {
100
+ return typeof value === 'string'
101
+ && value.length % 4 === 0
102
+ && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
103
+ }
104
+
105
+ function promptCapabilities(agentCapabilities) {
106
+ const capabilities = agentCapabilities?.promptCapabilities
107
+ return capabilities && typeof capabilities === 'object' && !Array.isArray(capabilities) ? capabilities : {}
108
+ }
109
+
110
+ function attachmentPrompt(message, agentCapabilities) {
111
+ const text = messageText(message)
112
+ if (!message || typeof message === 'string') return [{ type: 'text', text }]
113
+ const attachments = message.attachments
114
+ const hasAttachmentRole = message.role === 'user-with-attachments'
115
+ const hasNonTextContent = Array.isArray(message.content) && message.content.some((part) => part && typeof part === 'object' && part.type !== 'text')
116
+ if (hasNonTextContent) throw attachmentError('OpenCode attachments must use the QuickForge attachments field.')
117
+ if (!hasAttachmentRole && attachments === undefined) return [{ type: 'text', text }]
118
+ if (!Array.isArray(attachments)) throw attachmentError('OpenCode attachments must be an array.')
119
+
120
+ const capabilities = promptCapabilities(agentCapabilities)
121
+ const prompt = text.trim() ? [{ type: 'text', text }] : []
122
+ attachments.forEach((attachment, index) => {
123
+ if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) {
124
+ throw attachmentError(`OpenCode attachment ${index + 1} is invalid.`)
125
+ }
126
+ if (attachment.type !== 'image' && attachment.type !== 'document') {
127
+ throw attachmentError(`OpenCode attachment ${index + 1} has an unsupported type.`)
128
+ }
129
+ if (typeof attachment.id !== 'string' || !attachment.id
130
+ || typeof attachment.fileName !== 'string' || !attachment.fileName
131
+ || typeof attachment.mimeType !== 'string' || !attachment.mimeType
132
+ || !Number.isFinite(attachment.size) || attachment.size < 0
133
+ || !isBase64(attachment.content)
134
+ || (attachment.extractedText !== undefined && typeof attachment.extractedText !== 'string')) {
135
+ throw attachmentError(`OpenCode attachment ${index + 1} has invalid fields.`)
136
+ }
137
+
138
+ if (attachment.type === 'image') {
139
+ if (!attachment.mimeType.startsWith('image/')) throw attachmentError(`OpenCode attachment ${index + 1} has an invalid image MIME type.`)
140
+ if (capabilities.image !== true) {
141
+ throw attachmentError('OpenCode does not advertise ACP image prompt support.', 'OPENCODE_IMAGE_UNSUPPORTED')
142
+ }
143
+ prompt.push({ type: 'image', data: attachment.content, mimeType: attachment.mimeType })
144
+ return
145
+ }
146
+
147
+ if (capabilities.embeddedContext !== true) {
148
+ throw attachmentError('OpenCode does not advertise ACP embedded resource prompt support.', 'OPENCODE_EMBEDDED_CONTEXT_UNSUPPORTED')
149
+ }
150
+ const resource = {
151
+ uri: `quickforge-attachment://prompt/${index + 1}`,
152
+ mimeType: attachment.mimeType,
153
+ ...(attachment.extractedText !== undefined
154
+ ? { text: attachment.extractedText }
155
+ : { blob: attachment.content }),
156
+ }
157
+ prompt.push({ type: 'resource', resource })
158
+ })
159
+
160
+ if (prompt.length === 0) throw attachmentError('OpenCode prompt must contain text or at least one attachment.')
161
+ return prompt
162
+ }
163
+
164
+ function stripMeta(value) {
165
+ if (Array.isArray(value)) return value.map(stripMeta)
166
+ if (!value || typeof value !== 'object') return value
167
+ const result = {}
168
+ for (const [key, item] of Object.entries(value)) {
169
+ if (key !== '_meta') result[key] = stripMeta(item)
170
+ }
171
+ return result
172
+ }
173
+
174
+ function sanitizeToolLocations(locations) {
175
+ if (!Array.isArray(locations)) return []
176
+ return locations.slice(0, TOOL_METADATA_LOCATION_LIMIT).map((location) => {
177
+ if (!location || typeof location.path !== 'string' || !location.path) return null
178
+ return {
179
+ path: location.path,
180
+ ...(Number.isInteger(location.line) && location.line >= 0 ? { line: location.line } : {}),
181
+ }
182
+ }).filter(Boolean)
183
+ }
184
+
185
+ function toolMetadata(update, fallback = {}) {
186
+ const title = typeof update?.title === 'string' && update.title ? update.title : fallback.title
187
+ const kind = typeof update?.kind === 'string' && update.kind ? update.kind : fallback.kind
188
+ const locations = sanitizeToolLocations(update?.locations)
189
+ return {
190
+ ...(title ? { title } : {}),
191
+ ...(kind ? { kind } : {}),
192
+ ...(locations.length > 0 ? { locations } : fallback.locations?.length ? { locations: fallback.locations } : {}),
193
+ }
194
+ }
195
+
196
+ function toolName(update) {
197
+ return ACP_TOOL_KIND_NAMES[update?.kind] || 'opencode_tool'
198
+ }
199
+
200
+ function normalizedToolInput(update, metadata) {
201
+ const raw = stripMeta(update?.rawInput ?? {})
202
+ const args = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : { rawInput: raw }
203
+ if (args.path === undefined && typeof args.file_path === 'string') args.path = args.file_path
204
+ if (args.path === undefined && typeof metadata.locations?.[0]?.path === 'string') args.path = metadata.locations[0].path
205
+ if (args.command === undefined && typeof args.cmd === 'string') args.command = args.cmd
206
+ if (args.query === undefined && typeof args.pattern === 'string') args.query = args.pattern
207
+ if (args.regex === undefined && typeof args.useRegex === 'boolean') args.regex = args.useRegex
208
+ args.__quickforgeAcp = metadata
209
+ return args
210
+ }
211
+
212
+ function historyToolMetadata(value, fallbackName, legacyName) {
213
+ const metadata = toolMetadata(value)
214
+ if (!metadata.title && !metadata.kind && fallbackName) metadata.kind = fallbackName
215
+ else if (legacyName && metadata.title !== fallbackName && metadata.kind !== fallbackName) metadata.kind = fallbackName
216
+ return metadata
217
+ }
218
+
219
+ function normalizeHistoryToolCall(block) {
220
+ const originalName = typeof block.name === 'string' && block.name ? block.name : 'Tool Call'
221
+ const rendererName = OPENCODE_RENDERER_TOOL_NAMES.has(originalName)
222
+ const name = rendererName ? originalName : ACP_TOOL_KIND_NAMES[originalName] || 'opencode_tool'
223
+ const rawArguments = stripMeta(block.arguments)
224
+ const args = rawArguments && typeof rawArguments === 'object' && !Array.isArray(rawArguments)
225
+ ? { ...rawArguments }
226
+ : { rawInput: rawArguments }
227
+ const metadata = historyToolMetadata(args.__quickforgeAcp, originalName, !rendererName)
228
+ args.__quickforgeAcp = metadata
229
+ return {
230
+ block: { ...block, name, arguments: args },
231
+ name,
232
+ metadata,
233
+ }
234
+ }
235
+
236
+ export function normalizeOpenCodeHistoryMessages(messages) {
237
+ if (!Array.isArray(messages)) return []
238
+ const toolCalls = new Map()
239
+ return messages.map((message) => {
240
+ if (message?.role === 'assistant' && message.api === 'acp' && message.provider === 'opencode' && Array.isArray(message.content)) {
241
+ let changed = false
242
+ const content = message.content.map((block) => {
243
+ if (block?.type !== 'toolCall') return block
244
+ const normalized = normalizeHistoryToolCall(block)
245
+ if (block.id !== undefined && block.id !== null) toolCalls.set(String(block.id), normalized)
246
+ changed = true
247
+ return normalized.block
248
+ })
249
+ return changed ? { ...message, content } : message
250
+ }
251
+
252
+ if (message?.role === 'toolResult' && message.toolCallId !== undefined && message.toolCallId !== null) {
253
+ const normalized = toolCalls.get(String(message.toolCallId))
254
+ if (!normalized) return message
255
+ const details = message.details && typeof message.details === 'object' && !Array.isArray(message.details)
256
+ ? { ...message.details, __quickforgeAcp: normalized.metadata }
257
+ : { __quickforgeAcp: normalized.metadata }
258
+ return { ...message, toolName: normalized.name, details }
259
+ }
260
+
261
+ return message
262
+ })
263
+ }
264
+
265
+ function safeJson(value) {
266
+ try {
267
+ return JSON.stringify(value)
268
+ } catch {
269
+ return String(value ?? '')
270
+ }
271
+ }
272
+
273
+ function boundedText(value, limit = TOOL_OUTPUT_LIMIT) {
274
+ const text = String(value ?? '')
275
+ return text.length > limit ? `${text.slice(0, limit)}\n…[truncated]` : text
276
+ }
277
+
278
+ function safeRawOutput(value) {
279
+ const text = typeof value === 'string' ? value : safeJson(stripMeta(value))
280
+ return boundedText(text
281
+ .replace(/-----BEGIN[\s\S]*?PRIVATE KEY-----[\s\S]*?-----END[\s\S]*?PRIVATE KEY-----/gi, '[private key]')
282
+ .replace(/\bBearer\s+[^\s,;"}]+/gi, 'Bearer [redacted]')
283
+ .replace(/(["']?(?:token|secret|password|api[-_ ]?key|private[-_ ]?key)["']?\s*[:=]\s*)["']?[^\s,;"}]+["']?/gi, '$1"[redacted]"'))
284
+ }
285
+
286
+ function diffDetails(item) {
287
+ const hasOldText = typeof item?.oldText === 'string'
288
+ const oldText = hasOldText ? item.oldText : ''
289
+ const newText = typeof item?.newText === 'string' ? item.newText : ''
290
+ const oldLines = oldText ? oldText.split('\n') : []
291
+ const newLines = newText ? newText.split('\n') : []
292
+ const path = typeof item?.path === 'string' ? item.path : ''
293
+ const text = hasOldText
294
+ ? [
295
+ `--- ${path || 'before'}`,
296
+ `+++ ${path || 'after'}`,
297
+ ...oldLines.map((line) => `-${line}`),
298
+ ...newLines.map((line) => `+${line}`),
299
+ ].join('\n')
300
+ : newText
301
+ return {
302
+ format: hasOldText ? 'unified' : 'raw',
303
+ path,
304
+ addedLines: newLines.length,
305
+ removedLines: oldLines.length,
306
+ text: boundedText(text),
307
+ }
308
+ }
309
+
310
+ function toolResult(update, fallbackMetadata = {}) {
311
+ const metadata = toolMetadata(update, fallbackMetadata)
312
+ const output = []
313
+ let diff
314
+ for (const item of Array.isArray(update?.content) ? update.content : []) {
315
+ if (item?.type === 'content') {
316
+ const block = item.content
317
+ if (block?.type === 'text' && typeof block.text === 'string') output.push(block.text)
318
+ else if (block?.type === 'resource' && typeof block.resource?.text === 'string') output.push(block.resource.text)
319
+ else if (block?.type === 'resource' && typeof block.resource?.blob === 'string') {
320
+ output.push(`[binary resource: ${block.resource.mimeType || 'application/octet-stream'}, ${block.resource.blob.length} base64 chars]`)
321
+ }
322
+ } else if (item?.type === 'diff') {
323
+ const nextDiff = diffDetails(item)
324
+ if (!diff) diff = nextDiff
325
+ output.push(`[diff: ${nextDiff.path || 'unknown path'}, +${nextDiff.addedLines} -${nextDiff.removedLines}]`)
326
+ } else if (item?.type === 'terminal' && typeof item.terminalId === 'string') {
327
+ output.push(`[terminal: ${item.terminalId}]`)
328
+ }
329
+ }
330
+ if (output.length === 0 && update?.rawOutput !== undefined) output.push(safeRawOutput(update.rawOutput))
331
+ const value = boundedText(output.filter(Boolean).join('\n'))
332
+ return {
333
+ content: value ? [{ type: 'text', text: value }] : [],
334
+ details: {
335
+ __quickforgeAcp: metadata,
336
+ ...(value ? { output: value } : {}),
337
+ ...(diff ? { diff } : {}),
338
+ },
339
+ }
340
+ }
341
+
342
+ function baseAssistantMessage(content, id, stopReason = 'stop') {
343
+ return {
344
+ role: 'assistant',
345
+ content,
346
+ api: 'acp',
347
+ provider: 'opencode',
348
+ model: 'opencode',
349
+ usage: {
350
+ input: 0,
351
+ output: 0,
352
+ cacheRead: 0,
353
+ cacheWrite: 0,
354
+ totalTokens: 0,
355
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
356
+ },
357
+ stopReason,
358
+ timestamp: Date.now(),
359
+ ...(id ? { id } : {}),
360
+ }
361
+ }
362
+
363
+ function assistantErrorMessage(text) {
364
+ return {
365
+ ...baseAssistantMessage([], undefined, 'error'),
366
+ errorMessage: text,
367
+ }
368
+ }
369
+
370
+ export function sanitizeOpenCodeDiagnostic(value) {
371
+ return String(value ?? '')
372
+ .replace(/-----BEGIN[\s\S]*?PRIVATE KEY-----[\s\S]*?-----END[\s\S]*?PRIVATE KEY-----/gi, '[private key]')
373
+ .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
374
+ .replace(/\b(token|secret|password|api[-_ ]?key|private[-_ ]?key)\b\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
375
+ .replace(/\b(?:https?|file):\/\/[^\s)\]}]+/gi, '[url]')
376
+ .replace(/\b[A-Za-z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, '[local path]')
377
+ .replace(/(^|[\s(])\/(?:Users|home|var|tmp|private|opt|etc)\/[^\s)\]}]+/g, '$1[local path]')
378
+ .replace(/\s+/g, ' ')
379
+ .trim()
380
+ .slice(0, DIAGNOSTIC_LIMIT)
381
+ }
382
+
383
+ function authRequiredError(error) {
384
+ return error?.code === -32000 || /auth(?:entication|orization)?\s+(?:is\s+)?required|not\s+authenticated|login required/i.test(error?.message || '')
385
+ }
386
+
387
+ function publicAcpError(error, stage, fallback = 'OpenCode ACP request failed.') {
388
+ if (error?.errorCode && error?.statusCode) return error
389
+ if (authRequiredError(error)) {
390
+ return Object.assign(new Error('OpenCode authentication is required. Complete OpenCode login in a terminal, then try again.'), {
391
+ statusCode: 401,
392
+ errorCode: 'OPENCODE_AUTH_REQUIRED',
393
+ stage,
394
+ })
395
+ }
396
+ const detail = sanitizeOpenCodeDiagnostic(error?.message)
397
+ return Object.assign(new Error(detail || fallback), {
398
+ statusCode: error?.statusCode || 503,
399
+ errorCode: error?.errorCode || 'OPENCODE_ACP_RUNTIME_ERROR',
400
+ stage,
401
+ })
402
+ }
403
+
404
+ function timeoutError(stage, timeoutMs) {
405
+ return Object.assign(new Error(`OpenCode ACP ${stage} timed out after ${timeoutMs}ms.`), {
406
+ statusCode: 504,
407
+ errorCode: 'OPENCODE_ACP_TIMEOUT',
408
+ stage,
409
+ })
410
+ }
411
+
412
+ function incompatibleError(message, stage = 'initialize') {
413
+ return Object.assign(new Error(message), {
414
+ statusCode: 503,
415
+ errorCode: 'OPENCODE_ACP_INCOMPATIBLE',
416
+ stage,
417
+ })
418
+ }
419
+
420
+ function capabilityEnabled(value) {
421
+ return value !== undefined && value !== null && value !== false
422
+ }
423
+
424
+ function optionalString(value) {
425
+ return typeof value === 'string' ? value : undefined
426
+ }
427
+
428
+ function sanitizeConfigSelectOption(option) {
429
+ if (!option || typeof option.value !== 'string' || typeof option.name !== 'string') return null
430
+ return {
431
+ value: option.value,
432
+ name: option.name,
433
+ ...(optionalString(option.description) !== undefined ? { description: option.description } : {}),
434
+ }
435
+ }
436
+
437
+ function sanitizeConfigSelectOptions(options) {
438
+ if (!Array.isArray(options)) return []
439
+ return options.map((option) => {
440
+ if (option && typeof option.group === 'string' && typeof option.name === 'string' && Array.isArray(option.options)) {
441
+ return {
442
+ group: option.group,
443
+ name: option.name,
444
+ options: option.options.map(sanitizeConfigSelectOption).filter(Boolean),
445
+ }
446
+ }
447
+ return sanitizeConfigSelectOption(option)
448
+ }).filter(Boolean)
449
+ }
450
+
451
+ function sanitizeConfigOption(option) {
452
+ if (!option || typeof option.id !== 'string' || typeof option.name !== 'string') return null
453
+ const common = {
454
+ id: option.id,
455
+ name: option.name,
456
+ ...(optionalString(option.description) !== undefined ? { description: option.description } : {}),
457
+ ...(optionalString(option.category) !== undefined ? { category: option.category } : {}),
458
+ }
459
+ if (option.type === 'boolean' && typeof option.currentValue === 'boolean') {
460
+ return { ...common, type: 'boolean', currentValue: option.currentValue }
461
+ }
462
+ if (option.type === 'select' && typeof option.currentValue === 'string') {
463
+ return { ...common, type: 'select', currentValue: option.currentValue, options: sanitizeConfigSelectOptions(option.options) }
464
+ }
465
+ return null
466
+ }
467
+
468
+ function sanitizeConfigOptions(configOptions) {
469
+ return Array.isArray(configOptions) ? configOptions.map(sanitizeConfigOption).filter(Boolean) : []
470
+ }
471
+
472
+ function sanitizeModes(modes) {
473
+ if (!modes || typeof modes.currentModeId !== 'string' || !Array.isArray(modes.availableModes)) return null
474
+ return {
475
+ currentModeId: modes.currentModeId,
476
+ availableModes: modes.availableModes.map((mode) => {
477
+ if (!mode || typeof mode.id !== 'string' || typeof mode.name !== 'string') return null
478
+ return {
479
+ id: mode.id,
480
+ name: mode.name,
481
+ ...(optionalString(mode.description) !== undefined ? { description: mode.description } : {}),
482
+ }
483
+ }).filter(Boolean),
484
+ }
485
+ }
486
+
487
+ function sanitizeAvailableCommands(availableCommands) {
488
+ if (!Array.isArray(availableCommands)) return []
489
+ return availableCommands.map((command) => {
490
+ if (!command || typeof command.name !== 'string' || typeof command.description !== 'string') return null
491
+ const input = command.input && typeof command.input.hint === 'string' ? { hint: command.input.hint } : undefined
492
+ return { name: command.name, description: command.description, ...(input ? { input } : {}) }
493
+ }).filter(Boolean)
494
+ }
495
+
496
+ function sanitizeSessionInfo(update) {
497
+ const result = {}
498
+ if (update?.title === null || typeof update?.title === 'string') result.title = update.title
499
+ if (update?.updatedAt === null || typeof update?.updatedAt === 'string') result.updatedAt = update.updatedAt
500
+ return result
501
+ }
502
+
503
+ function sanitizeUsage(update) {
504
+ if (!Number.isFinite(update?.used) || !Number.isFinite(update?.size)) return null
505
+ const usage = { used: update.used, size: update.size }
506
+ if (update.cost && Number.isFinite(update.cost.amount) && typeof update.cost.currency === 'string') {
507
+ usage.cost = { amount: update.cost.amount, currency: update.cost.currency }
508
+ } else if (update?.cost === null) {
509
+ usage.cost = null
510
+ }
511
+ return usage
512
+ }
513
+
514
+ function configOptionValues(option) {
515
+ if (option?.type !== 'select' || !Array.isArray(option.options)) return []
516
+ return option.options.flatMap((item) => Array.isArray(item?.options) ? item.options : [item]).map((item) => item?.value).filter((value) => typeof value === 'string')
517
+ }
518
+
519
+ export function normalizeOpenCodeSessionSetupResult(result) {
520
+ return {
521
+ configOptions: sanitizeConfigOptions(result?.configOptions),
522
+ modes: sanitizeModes(result?.modes),
523
+ }
524
+ }
525
+
526
+ export class OpenCodeAcpAgent {
527
+ constructor({
528
+ sessionId,
529
+ cwd,
530
+ messages = [],
531
+ harnessSessionId = null,
532
+ sourceHarnessSessionId = null,
533
+ restoredUsage = null,
534
+ requestPermission,
535
+ logger,
536
+ timeouts = {},
537
+ dependencies = {},
538
+ }) {
539
+ this.sessionId = sessionId
540
+ this.cwd = cwd
541
+ this.harnessSessionId = harnessSessionId
542
+ this.sourceHarnessSessionId = sourceHarnessSessionId
543
+ this.restoredUsage = sanitizeUsage(restoredUsage)
544
+ this.requestPermission = requestPermission
545
+ this.logger = logger
546
+ this.timeouts = { ...DEFAULT_OPENCODE_ACP_TIMEOUTS, ...timeouts }
547
+ this.dependencies = {
548
+ resolveCommand: resolveOpenCodeCommand,
549
+ spawnOpenCode,
550
+ terminateProcessTree,
551
+ setTimer: setTimeout,
552
+ clearTimer: clearTimeout,
553
+ ...dependencies,
554
+ }
555
+ this.listeners = new Set()
556
+ this.toolCalls = new Map()
557
+ this.acceptUpdates = false
558
+ this.disposed = false
559
+ this.connection = null
560
+ this.context = null
561
+ this.process = null
562
+ this.stderr = ''
563
+ this.idleWaiters = new Set()
564
+ this.abortController = null
565
+ this.signal = undefined
566
+ this.currentPromptPromise = null
567
+ this.promptRunSequence = 0
568
+ this.activePromptRunId = 0
569
+ this.runtimeFailure = null
570
+ this.runtimeFailureHandled = false
571
+ this.runtimeTerminationPromise = null
572
+ this.activeRunEnded = false
573
+ this.initialized = false
574
+ this.initializeResult = null
575
+ this.protocolVersion = null
576
+ this.agentInfo = null
577
+ this.agentCapabilities = {}
578
+ this.authMethods = []
579
+ this.setupMetadataBuffer = []
580
+ this.runtimeFailurePromise = new Promise((resolve) => { this.resolveRuntimeFailure = resolve })
581
+ this.state = {
582
+ systemPrompt: '',
583
+ model: null,
584
+ thinkingLevel: 'off',
585
+ messages: normalizeOpenCodeHistoryMessages(messages),
586
+ tools: [],
587
+ isStreaming: false,
588
+ streamingMessage: undefined,
589
+ pendingToolCalls: new Set(),
590
+ errorMessage: undefined,
591
+ acpSession: {
592
+ configOptions: [],
593
+ modes: null,
594
+ availableCommands: [],
595
+ sessionInfo: {},
596
+ usage: this.restoredUsage,
597
+ },
598
+ }
599
+ }
600
+
601
+ async initialize() {
602
+ const command = await this.dependencies.resolveCommand()
603
+ const child = this.dependencies.spawnOpenCode(command, this.cwd, this.dependencies.spawnImpl, this.dependencies.platform)
604
+ this.process = child
605
+ child.stdin?.on('error', () => {})
606
+ child.stdout?.on('error', () => {})
607
+ child.stderr?.setEncoding('utf8')
608
+ child.stderr?.on('data', (chunk) => {
609
+ this.stderr = sanitizeOpenCodeDiagnostic(`${this.stderr}${chunk}`).slice(-DIAGNOSTIC_LIMIT)
610
+ })
611
+ child.once('error', (error) => this.handleRuntimeFailure(error, 'process'))
612
+ child.once('exit', (code, signal) => {
613
+ if (this.disposed) return
614
+ const exitReason = signal || (code ?? 'unknown')
615
+ const detail = this.stderr.trim()
616
+ this.handleRuntimeFailure(new Error(`OpenCode ACP process exited (${exitReason}).${detail ? ` ${detail}` : ''}`), 'process')
617
+ })
618
+
619
+ const app = client({ name: ACP_CLIENT_NAME })
620
+ .onRequest(methods.client.session.requestPermission, ({ params }) => this.handlePermissionRequest(params))
621
+ .onNotification(methods.client.session.update, ({ params }) => this.handleSessionUpdate(params))
622
+ .onRequest(methods.client.fs.readTextFile, async () => { throw new Error('OpenCode must use its native filesystem tools.') })
623
+ .onRequest(methods.client.fs.writeTextFile, async () => { throw new Error('OpenCode must use its native filesystem tools.') })
624
+
625
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout))
626
+ const connection = app.connect(stream)
627
+ this.connection = connection
628
+ void connection.closed.then(
629
+ () => this.handleRuntimeFailure(new Error('OpenCode ACP connection closed.'), 'transport'),
630
+ (error) => this.handleRuntimeFailure(error, 'transport'),
631
+ )
632
+ this.context = connection.agent
633
+
634
+ const initialized = await this.requestWithDeadline('initialize', this.timeouts.initialize, () => this.context.request(methods.agent.initialize, {
635
+ protocolVersion: PROTOCOL_VERSION,
636
+ clientCapabilities: {},
637
+ clientInfo: { name: ACP_CLIENT_NAME, version: '1' },
638
+ }))
639
+ this.validateInitializeResult(initialized)
640
+ this.initialized = true
641
+
642
+ if (this.harnessSessionId) {
643
+ await this.loadExistingSession()
644
+ } else if (this.sourceHarnessSessionId) {
645
+ this.requireCapability(this.supportsSessionCapability('fork'), 'OpenCode does not advertise ACP session/fork support.', 'session/fork')
646
+ const forked = await this.requestWithDeadline('session/fork', this.timeouts.sessionSetup, () => this.context.request(methods.agent.session.fork, {
647
+ sessionId: this.sourceHarnessSessionId,
648
+ cwd: this.cwd,
649
+ mcpServers: [],
650
+ }))
651
+ this.harnessSessionId = forked.sessionId
652
+ this.applySessionSetupResult(forked)
653
+ } else {
654
+ const created = await this.requestWithDeadline('session/new', this.timeouts.sessionSetup, () => this.context.request(methods.agent.session.new, {
655
+ cwd: this.cwd,
656
+ mcpServers: [],
657
+ }))
658
+ this.harnessSessionId = created.sessionId
659
+ this.applySessionSetupResult(created)
660
+ }
661
+ this.acceptUpdates = true
662
+ this.flushSetupMetadataBuffer()
663
+ return this
664
+ }
665
+
666
+ validateInitializeResult(result) {
667
+ if (!result || result.protocolVersion !== PROTOCOL_VERSION) {
668
+ throw incompatibleError(`OpenCode ACP protocol version ${result?.protocolVersion ?? 'unknown'} is incompatible with QuickForge protocol version ${PROTOCOL_VERSION}.`)
669
+ }
670
+ if (result.agentCapabilities != null && (typeof result.agentCapabilities !== 'object' || Array.isArray(result.agentCapabilities))) {
671
+ throw incompatibleError('OpenCode returned invalid ACP agent capabilities.')
672
+ }
673
+ if (result.authMethods != null && !Array.isArray(result.authMethods)) {
674
+ throw incompatibleError('OpenCode returned invalid ACP authentication methods.')
675
+ }
676
+ this.initializeResult = result
677
+ this.protocolVersion = result.protocolVersion
678
+ this.agentInfo = result.agentInfo ?? null
679
+ this.agentCapabilities = result.agentCapabilities ?? {}
680
+ this.authMethods = result.authMethods ?? []
681
+ }
682
+
683
+ supportsSessionCapability(name) {
684
+ return capabilityEnabled(this.agentCapabilities?.sessionCapabilities?.[name])
685
+ }
686
+
687
+ requireCapability(supported, message, stage) {
688
+ if (!supported) throw incompatibleError(message, stage)
689
+ }
690
+
691
+ async loadExistingSession() {
692
+ const params = { sessionId: this.harnessSessionId, cwd: this.cwd, mcpServers: [] }
693
+ let loadError
694
+ if (this.agentCapabilities?.loadSession === true) {
695
+ try {
696
+ const loaded = await this.requestWithDeadline('session/load', this.timeouts.sessionSetup, () => this.context.request(methods.agent.session.load, params))
697
+ this.applySessionSetupResult(loaded)
698
+ return
699
+ } catch (error) {
700
+ loadError = error
701
+ }
702
+ }
703
+ if (this.supportsSessionCapability('resume')) {
704
+ try {
705
+ const resumed = await this.requestWithDeadline('session/resume', this.timeouts.sessionSetup, () => this.context.request(methods.agent.session.resume, params))
706
+ this.applySessionSetupResult(resumed)
707
+ return
708
+ } catch (error) {
709
+ throw publicAcpError(error, 'session/resume', `OpenCode session ${this.harnessSessionId} could not be restored.`)
710
+ }
711
+ }
712
+ if (loadError) throw publicAcpError(loadError, 'session/load', `OpenCode session ${this.harnessSessionId} could not be restored.`)
713
+ throw incompatibleError('OpenCode does not advertise ACP session/load or session/resume support.', 'session/restore')
714
+ }
715
+
716
+ applySessionSetupResult(result) {
717
+ const normalized = normalizeOpenCodeSessionSetupResult(result)
718
+ this.state.acpSession.configOptions = normalized.configOptions
719
+ this.state.acpSession.modes = normalized.modes
720
+ }
721
+
722
+ bufferSetupMetadataUpdate(params) {
723
+ if (params?.sessionId !== this.harnessSessionId && this.harnessSessionId) return
724
+ if (!ACP_METADATA_UPDATE_TYPES.has(params?.update?.sessionUpdate)) return
725
+ if (this.setupMetadataBuffer.length >= SETUP_METADATA_BUFFER_LIMIT) this.setupMetadataBuffer.shift()
726
+ this.setupMetadataBuffer.push(params)
727
+ }
728
+
729
+ flushSetupMetadataBuffer() {
730
+ const buffered = this.setupMetadataBuffer
731
+ this.setupMetadataBuffer = []
732
+ for (const params of buffered) this.handleSessionUpdate(params)
733
+ }
734
+
735
+ clearSetupMetadataBuffer() {
736
+ this.setupMetadataBuffer = []
737
+ }
738
+
739
+ applyMetadataUpdate(update) {
740
+ switch (update?.sessionUpdate) {
741
+ case 'available_commands_update':
742
+ this.state.acpSession.availableCommands = sanitizeAvailableCommands(update.availableCommands)
743
+ return true
744
+ case 'current_mode_update': {
745
+ if (typeof update.currentModeId !== 'string') return true
746
+ const modes = this.state.acpSession.modes
747
+ this.state.acpSession.modes = modes
748
+ ? { ...modes, currentModeId: update.currentModeId }
749
+ : { currentModeId: update.currentModeId, availableModes: [] }
750
+ return true
751
+ }
752
+ case 'config_option_update':
753
+ this.state.acpSession.configOptions = sanitizeConfigOptions(update.configOptions)
754
+ return true
755
+ case 'session_info_update':
756
+ this.state.acpSession.sessionInfo = { ...this.state.acpSession.sessionInfo, ...sanitizeSessionInfo(update) }
757
+ return true
758
+ case 'usage_update':
759
+ this.state.acpSession.usage = sanitizeUsage(update)
760
+ this.emit({ type: 'acp_session_usage_update', sessionId: this.harnessSessionId, usage: this.state.acpSession.usage })
761
+ return true
762
+ default:
763
+ return false
764
+ }
765
+ }
766
+
767
+ requestWithDeadline(stage, timeoutMs, request, options = {}) {
768
+ if (this.runtimeFailure && options.includeRuntimeFailure !== false) return Promise.reject(this.runtimeFailure)
769
+ let timer
770
+ let deadlineError = null
771
+ const timeout = new Promise((_, reject) => {
772
+ timer = this.dependencies.setTimer(() => {
773
+ deadlineError = timeoutError(stage, timeoutMs)
774
+ reject(deadlineError)
775
+ }, timeoutMs)
776
+ timer?.unref?.()
777
+ })
778
+ const contenders = [Promise.resolve().then(request), timeout]
779
+ if (options.includeRuntimeFailure !== false) {
780
+ contenders.push(this.runtimeFailurePromise.then((error) => Promise.reject(error)))
781
+ }
782
+ return Promise.race(contenders)
783
+ .catch((error) => {
784
+ const publicError = publicAcpError(error, stage)
785
+ if (error === deadlineError && options.fatal !== false) this.handleRuntimeFailure(publicError, stage)
786
+ throw publicError
787
+ })
788
+ .finally(() => this.dependencies.clearTimer(timer))
789
+ }
790
+
791
+ terminateFailedRuntime() {
792
+ if (this.runtimeTerminationPromise) return this.runtimeTerminationPromise
793
+ try { this.connection?.close() } catch { /* best-effort transport cleanup */ }
794
+ this.runtimeTerminationPromise = Promise.resolve(this.dependencies.terminateProcessTree(this.process)).catch(() => {})
795
+ return this.runtimeTerminationPromise
796
+ }
797
+
798
+ subscribe(listener) {
799
+ this.listeners.add(listener)
800
+ return () => this.listeners.delete(listener)
801
+ }
802
+
803
+ emit(event) {
804
+ for (const listener of this.listeners) {
805
+ try { listener(event) } catch { /* ignore listener failures */ }
806
+ }
807
+ }
808
+
809
+ validatePrompt(message) {
810
+ return attachmentPrompt(message, this.agentCapabilities)
811
+ }
812
+
813
+ async prompt(message) {
814
+ if (this.disposed) throw new Error('OpenCode session is closed.')
815
+ if (this.state.isStreaming) throw new Error('Generation is still running.')
816
+ if (this.runtimeFailure) throw this.runtimeFailure
817
+ const prompt = this.validatePrompt(message)
818
+ const text = messageText(message)
819
+ const userMessage = typeof message === 'string'
820
+ ? { role: 'user', content: text, timestamp: Date.now() }
821
+ : message
822
+ const isInitialUserMessage = this.state.messages.length === 0
823
+ this.state.messages = [...this.state.messages, userMessage]
824
+ this.emit({ type: 'message_start', message: userMessage })
825
+ this.emit({ type: 'message_end', message: userMessage, isInitialUserMessage })
826
+ this.state.isStreaming = true
827
+ this.state.errorMessage = undefined
828
+ this.abortController = new AbortController()
829
+ const promptAbortController = this.abortController
830
+ this.signal = promptAbortController.signal
831
+ this.activeRunEnded = false
832
+ const runId = ++this.promptRunSequence
833
+ this.activePromptRunId = runId
834
+ this.emit({ type: 'agent_start' })
835
+
836
+ const run = async () => {
837
+ try {
838
+ const result = await this.requestWithDeadline('session/prompt', this.timeouts.prompt, () => this.context.request(methods.agent.session.prompt, {
839
+ sessionId: this.harnessSessionId,
840
+ prompt,
841
+ }))
842
+ if (promptAbortController.signal.aborted || this.activeRunEnded || this.activePromptRunId !== runId) return
843
+ this.finishAssistantMessage(result.stopReason)
844
+ this.state.isStreaming = false
845
+ this.signal = undefined
846
+ this.activeRunEnded = true
847
+ this.emit({ type: 'agent_end', messages: this.state.messages, stopReason: result.stopReason })
848
+ this.resolveIdleWaiters()
849
+ } catch (error) {
850
+ if (promptAbortController.signal.aborted || this.activePromptRunId !== runId) {
851
+ if (this.activePromptRunId !== runId) return
852
+ this.finishAssistantMessage()
853
+ this.clearRunState()
854
+ return
855
+ }
856
+ const publicError = publicAcpError(error, error?.stage || 'session/prompt', 'OpenCode ACP prompt failed.')
857
+ if (!this.activeRunEnded) this.failActiveRun(publicError)
858
+ throw publicError
859
+ } finally {
860
+ if (this.activePromptRunId === runId) this.currentPromptPromise = null
861
+ }
862
+ }
863
+ this.currentPromptPromise = run()
864
+ await this.currentPromptPromise
865
+ }
866
+
867
+ clearRunState() {
868
+ this.activePromptRunId = 0
869
+ this.currentPromptPromise = null
870
+ this.state.isStreaming = false
871
+ this.state.streamingMessage = undefined
872
+ this.state.pendingToolCalls = new Set()
873
+ this.toolCalls.clear()
874
+ this.signal = undefined
875
+ this.activeRunEnded = true
876
+ this.resolveIdleWaiters()
877
+ }
878
+
879
+ failActiveRun(error) {
880
+ if (this.activeRunEnded) return
881
+ this.finishAssistantMessage()
882
+ const errorMessage = error?.message || 'OpenCode ACP runtime failed.'
883
+ const last = this.state.messages[this.state.messages.length - 1]
884
+ if (!(last?.role === 'assistant' && last?.stopReason === 'error' && last?.errorMessage)) {
885
+ const message = assistantErrorMessage(errorMessage)
886
+ this.state.messages = [...this.state.messages, message]
887
+ this.emit({ type: 'message_start', message })
888
+ this.emit({ type: 'message_end', message })
889
+ }
890
+ this.state.isStreaming = false
891
+ this.state.streamingMessage = undefined
892
+ this.state.pendingToolCalls = new Set()
893
+ this.toolCalls.clear()
894
+ this.state.errorMessage = errorMessage
895
+ this.signal = undefined
896
+ this.activeRunEnded = true
897
+ this.emit({ type: 'error', error: errorMessage, errorCode: error?.errorCode, stage: error?.stage })
898
+ this.emit({ type: 'agent_end', messages: this.state.messages, errorMessage, stopReason: 'error' })
899
+ this.resolveIdleWaiters()
900
+ }
901
+
902
+ handleRuntimeFailure(error, stage = 'runtime') {
903
+ if (this.disposed || this.runtimeFailureHandled) return
904
+ this.runtimeFailureHandled = true
905
+ this.clearSetupMetadataBuffer()
906
+ const runtimeError = publicAcpError(error, stage, 'OpenCode runtime is unavailable.')
907
+ this.runtimeFailure = runtimeError
908
+ this.acceptUpdates = false
909
+ this.resolveRuntimeFailure(runtimeError)
910
+ if (this.state.isStreaming) this.failActiveRun(runtimeError)
911
+ else this.state.errorMessage = runtimeError.message
912
+ void this.terminateFailedRuntime()
913
+ }
914
+
915
+ appendAssistantChunk(update, type) {
916
+ const text = update?.content?.text || ''
917
+ if (!text) return
918
+ const current = this.state.streamingMessage
919
+ const id = String(update.messageId || current?.id || `opencode-${Date.now()}`)
920
+ if (current && current.id !== id) this.finishAssistantMessage()
921
+ const active = this.state.streamingMessage
922
+ const block = type === 'thinking' ? { type: 'thinking', thinking: text } : { type: 'text', text }
923
+ const content = active ? active.content.slice() : []
924
+ const last = content[content.length - 1]
925
+ if (type === 'thinking' && last?.type === 'thinking') last.thinking = `${last.thinking || ''}${text}`
926
+ else if (type === 'text' && last?.type === 'text') last.text = `${last.text || ''}${text}`
927
+ else content.push(block)
928
+ const next = baseAssistantMessage(content, id)
929
+ this.state.streamingMessage = next
930
+ this.emit({ type: active ? 'message_update' : 'message_start', message: next })
931
+ }
932
+
933
+ handleSessionUpdate(params) {
934
+ if (!this.acceptUpdates) {
935
+ if (!this.initialized || this.disposed || this.runtimeFailure) return
936
+ this.bufferSetupMetadataUpdate(params)
937
+ return
938
+ }
939
+ if (params?.sessionId !== this.harnessSessionId) return
940
+ const update = params.update
941
+ if (this.applyMetadataUpdate(update)) return
942
+ if (update?.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
943
+ this.appendAssistantChunk(update, 'text')
944
+ return
945
+ }
946
+ if (update?.sessionUpdate === 'agent_thought_chunk' && update.content?.type === 'text') {
947
+ this.appendAssistantChunk(update, 'thinking')
948
+ return
949
+ }
950
+
951
+ if (update?.sessionUpdate === 'tool_call') {
952
+ this.finishAssistantMessage()
953
+ const id = String(update.toolCallId)
954
+ const name = toolName(update)
955
+ const metadata = toolMetadata(update)
956
+ const args = normalizedToolInput(update, metadata)
957
+ this.toolCalls.set(id, { name, input: args, metadata })
958
+ const message = baseAssistantMessage([{ type: 'toolCall', id, name, arguments: args }], update.messageId)
959
+ message.details = { __quickforgeAcp: metadata }
960
+ this.state.messages = [...this.state.messages, message]
961
+ this.emit({ type: 'message_start', message })
962
+ this.emit({ type: 'message_end', message })
963
+ this.state.pendingToolCalls = new Set([...this.state.pendingToolCalls, id])
964
+ this.emit({ type: 'tool_execution_start', toolCallId: id, toolName: name, args, details: { __quickforgeAcp: metadata } })
965
+ return
966
+ }
967
+
968
+ if (update?.sessionUpdate === 'tool_call_update') {
969
+ const id = String(update.toolCallId)
970
+ const previous = this.toolCalls.get(id)
971
+ if (!previous) return
972
+ const name = previous.name
973
+ const metadata = toolMetadata(update, previous.metadata)
974
+ previous.metadata = metadata
975
+ if (update.status === 'completed' || update.status === 'failed') {
976
+ this.state.pendingToolCalls = new Set([...this.state.pendingToolCalls].filter((item) => item !== id))
977
+ this.toolCalls.delete(id)
978
+ const result = toolResult(update, metadata)
979
+ this.state.messages = [...this.state.messages, {
980
+ role: 'toolResult',
981
+ toolCallId: id,
982
+ toolName: name,
983
+ content: result.content,
984
+ details: result.details,
985
+ isError: update.status === 'failed',
986
+ timestamp: Date.now(),
987
+ }]
988
+ this.emit({
989
+ type: 'tool_execution_end',
990
+ toolCallId: id,
991
+ toolName: name,
992
+ args: previous.input,
993
+ result,
994
+ isError: update.status === 'failed',
995
+ error: update.status === 'failed' ? sanitizeOpenCodeDiagnostic(update.rawOutput) : undefined,
996
+ details: { __quickforgeAcp: metadata },
997
+ })
998
+ } else {
999
+ this.emit({
1000
+ type: 'tool_execution_update',
1001
+ toolCallId: id,
1002
+ toolName: name,
1003
+ args: previous.input,
1004
+ partialResult: toolResult(update, metadata),
1005
+ details: { __quickforgeAcp: metadata },
1006
+ })
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ finishAssistantMessage(stopReason) {
1012
+ const current = this.state.streamingMessage
1013
+ if (!current) return
1014
+ const message = stopReason === undefined ? current : { ...current, stopReason }
1015
+ this.state.messages = [...this.state.messages, message]
1016
+ this.state.streamingMessage = undefined
1017
+ this.emit({ type: 'message_end', message })
1018
+ }
1019
+
1020
+ async handlePermissionRequest(params) {
1021
+ if (!this.requestPermission) return { outcome: { outcome: 'cancelled' } }
1022
+ return this.requestPermission({
1023
+ toolCallId: String(params.toolCall?.toolCallId || ''),
1024
+ toolName: params.toolCall?.title || params.toolCall?.kind || 'OpenCode tool',
1025
+ args: params.toolCall?.rawInput,
1026
+ options: params.options || [],
1027
+ })
1028
+ }
1029
+
1030
+ async setConfigOption(configId, value) {
1031
+ if (this.disposed) throw new Error('OpenCode session is closed.')
1032
+ if (this.state.isStreaming) throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), { statusCode: 409 })
1033
+ const option = this.state.acpSession.configOptions.find((item) => item.id === configId)
1034
+ if (!option) throw Object.assign(new Error(`OpenCode did not advertise config option: ${configId}`), { statusCode: 400 })
1035
+ if (option.type === 'boolean') {
1036
+ if (typeof value !== 'boolean') throw Object.assign(new Error(`Config option ${configId} requires a boolean value.`), { statusCode: 400 })
1037
+ } else if (option.type === 'select') {
1038
+ if (typeof value !== 'string' || !configOptionValues(option).includes(value)) {
1039
+ throw Object.assign(new Error(`Invalid value for OpenCode config option: ${configId}`), { statusCode: 400 })
1040
+ }
1041
+ } else {
1042
+ throw Object.assign(new Error(`Unsupported OpenCode config option type: ${configId}`), { statusCode: 400 })
1043
+ }
1044
+ const params = {
1045
+ sessionId: this.harnessSessionId,
1046
+ configId,
1047
+ value,
1048
+ ...(option.type === 'boolean' ? { type: 'boolean' } : {}),
1049
+ }
1050
+ const result = await this.requestWithDeadline('session/set_config_option', this.timeouts.sessionSetup, () => this.context.request(methods.agent.session.setConfigOption, params), { fatal: false })
1051
+ this.state.acpSession.configOptions = sanitizeConfigOptions(result?.configOptions)
1052
+ return this.state.acpSession
1053
+ }
1054
+
1055
+ async setMode(modeId) {
1056
+ if (this.disposed) throw new Error('OpenCode session is closed.')
1057
+ if (this.state.isStreaming) throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes.'), { statusCode: 409 })
1058
+ const modes = this.state.acpSession.modes
1059
+ if (!modes?.availableModes.some((mode) => mode.id === modeId)) {
1060
+ throw Object.assign(new Error(`OpenCode did not advertise mode: ${modeId}`), { statusCode: 400 })
1061
+ }
1062
+ await this.requestWithDeadline('session/set_mode', this.timeouts.sessionSetup, () => this.context.request(methods.agent.session.setMode, {
1063
+ sessionId: this.harnessSessionId,
1064
+ modeId,
1065
+ }), { fatal: false })
1066
+ this.state.acpSession.modes = { ...modes, currentModeId: modeId }
1067
+ return this.state.acpSession
1068
+ }
1069
+
1070
+ steer() {
1071
+ throw new Error('Steering is not supported by the OpenCode Harness.')
1072
+ }
1073
+
1074
+ followUp() {
1075
+ throw new Error('Follow-up queuing is not supported by the OpenCode Harness.')
1076
+ }
1077
+
1078
+ reset() {
1079
+ this.state.messages = []
1080
+ this.state.streamingMessage = undefined
1081
+ this.state.pendingToolCalls = new Set()
1082
+ this.state.errorMessage = undefined
1083
+ this.toolCalls.clear()
1084
+ }
1085
+
1086
+ abort() {
1087
+ if (!this.state.isStreaming) return
1088
+ this.finishAssistantMessage()
1089
+ this.abortController?.abort()
1090
+ this.activePromptRunId = 0
1091
+ this.currentPromptPromise = null
1092
+ this.state.isStreaming = false
1093
+ this.state.streamingMessage = undefined
1094
+ this.state.pendingToolCalls = new Set()
1095
+ this.toolCalls.clear()
1096
+ this.signal = undefined
1097
+ this.activeRunEnded = true
1098
+ this.resolveIdleWaiters()
1099
+ if (this.context && this.harnessSessionId) {
1100
+ void this.context.notify(methods.agent.session.cancel, { sessionId: this.harnessSessionId }).catch(() => {})
1101
+ }
1102
+ }
1103
+
1104
+ waitForIdle() {
1105
+ if (!this.state.isStreaming) return Promise.resolve()
1106
+ if (this.currentPromptPromise) return this.currentPromptPromise.catch(() => {})
1107
+ return new Promise((resolve) => this.idleWaiters.add(resolve))
1108
+ }
1109
+
1110
+ resolveIdleWaiters() {
1111
+ for (const resolve of this.idleWaiters) resolve()
1112
+ this.idleWaiters.clear()
1113
+ }
1114
+
1115
+ async dispose() {
1116
+ if (this.disposed) return
1117
+ this.abort()
1118
+ this.disposed = true
1119
+ this.acceptUpdates = false
1120
+ this.clearSetupMetadataBuffer()
1121
+ try {
1122
+ if (this.context && this.harnessSessionId && this.supportsSessionCapability('close')) {
1123
+ await this.requestWithDeadline('session/close', this.timeouts.close, () => this.context.request(methods.agent.session.close, {
1124
+ sessionId: this.harnessSessionId,
1125
+ }), { includeRuntimeFailure: false, fatal: false }).catch(() => {})
1126
+ }
1127
+ } finally {
1128
+ this.connection?.close()
1129
+ await (this.runtimeTerminationPromise ?? Promise.resolve(this.dependencies.terminateProcessTree(this.process)).catch(() => {}))
1130
+ this.clearRunState()
1131
+ this.listeners.clear()
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ export async function createOpenCodeAcpAgent(options) {
1137
+ const agent = new OpenCodeAcpAgent(options)
1138
+ try {
1139
+ return await agent.initialize()
1140
+ } catch (error) {
1141
+ agent.acceptUpdates = false
1142
+ await agent.dispose().catch(() => {})
1143
+ if (error?.code === 'ENOENT') {
1144
+ throw Object.assign(new Error('OpenCode is unavailable. Install OpenCode and ensure the opencode command is on PATH.'), {
1145
+ statusCode: 503,
1146
+ errorCode: 'OPENCODE_UNAVAILABLE',
1147
+ })
1148
+ }
1149
+ throw publicAcpError(error, error?.stage || 'initialize')
1150
+ }
1151
+ }