@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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 (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. package/src/sdk-wave-e.ts +1 -1
@@ -1,1554 +1,1554 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const { writeJson } = require('../json-file');
5
- const os = require('os');
6
- const path = require('path');
7
- const crypto = require('crypto');
8
- const { createCliJsonRunner, resolveCliInvocation } = require('./cli-json-runner');
9
- const { createOpenCodeServerBridge } = require('../bridges/opencode-server-bridge');
10
- const { stampEvent, resequenceRestoredEvents } = require('../event-stream');
11
- const {
12
- appendTextAttachments, normalizeAttachments, resolveOpenAIModel, resolveOpenAIBaseUrl,
13
- } = require('../../../contracts');
14
-
15
- const PROVIDER_ID = 'agent-terminal';
16
- const PROVIDER_KEY_ENV = 'AGENT_TERMINAL_PROVIDER_API_KEY';
17
- const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
18
-
19
- // OpenCode driver, two transports (mirrors the codex driver's shape):
20
- // serve (preferred) — one persistent `opencode serve` child per provider
21
- // spec speaking HTTP + SSE, giving true token streaming
22
- // (`message.part.delta`), live tool progress, per-session abort and
23
- // multi-turn sessions without respawning. Protocol verified live
24
- // against opencode 1.18.5 (see bridges/opencode-server-bridge.js).
25
- // cli-json (fallback) — the historical `opencode run --format json`
26
- // one-shot turns, used automatically when `spawn` is injected (the test
27
- // double contract), OPENCODE_TRANSPORT=cli is set, or the serve child
28
- // cannot start / keeps crashing.
29
- //
30
- // Auth is agent-owned (opencode auth) or profile-injected; workbench
31
- // permission modes map to per-session opencode permission rulesets on serve
32
- // (verified live: a denied permission removes the tool from the model's
33
- // toolset) and to OPENCODE_CONFIG_CONTENT permission objects on the CLI.
34
-
35
- function createOpenCodeDriver(options = {}) {
36
- // Test doubles inject `spawn` and script CLI subprocess behavior; honoring
37
- // that injection with the CLI path keeps the legacy contract intact.
38
- const forcedCli = options.bridge === false
39
- || process.env.OPENCODE_TRANSPORT === 'cli'
40
- || Boolean(options.spawn);
41
- if (!forcedCli && (options.serveSpawn || options.serveFetch || opencodeCommandExists(options.command || 'opencode'))) {
42
- return createServeOpenCodeDriver(options);
43
- }
44
- return createCliOpenCodeRunner(options);
45
- }
46
-
47
- // ---------------------------------------------------------------------------
48
- // CLI transport (previous behavior, kept verbatim as the fallback)
49
- // ---------------------------------------------------------------------------
50
-
51
- function createCliOpenCodeRunner(options = {}) {
52
- return createCliJsonRunner({
53
- agentId: 'opencode',
54
- command: options.command || 'opencode',
55
- spawn: options.spawn,
56
- promptViaStdin: true,
57
- ...(options.persistence !== undefined ? { persistence: options.persistence } : {}),
58
-
59
- modelFor(profile) {
60
- const model = resolveOpenAIModel(profile);
61
- if (!model) return null;
62
- return /^[A-Za-z0-9._:/-]+$/.test(model) ? `${isInjected(profile) ? `${PROVIDER_ID}/` : ''}${model}` : null;
63
- },
64
-
65
- envFor(profile, session) {
66
- const env = {};
67
- if (isInjected(profile)) {
68
- env[PROVIDER_KEY_ENV] = profile.apiKey;
69
- env.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfigFor(profile, session));
70
- return env;
71
- }
72
- env.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfigFor(profile, session));
73
- return env;
74
- },
75
-
76
- spawnArgs({ projectPath, model, threadId, imagePaths, permissionMode }) {
77
- const args = ['run', '--format', 'json'];
78
- // The native exe ignores spawn cwd when driven over stdin on Windows and
79
- // resolves its project root from the parent process instead; --dir pins
80
- // the workspace explicitly for every platform.
81
- args.push('--dir', projectPath);
82
- if (model) args.push('-m', model);
83
- if (threadId) args.push('--session', threadId);
84
- if (permissionMode === 'plan') args.push('--agent', 'plan');
85
- // No effort flag: opencode's --variant selects agent variants, not
86
- // reasoning effort, so passing effort levels there would be a lie.
87
- for (const file of imagePaths || []) args.push('-f', file);
88
- return args;
89
- },
90
-
91
- parseLine: parseOpenCodeLine,
92
- });
93
- }
94
-
95
- // Shared config body for the CLI transport: permission object (per turn,
96
- // because every turn respawns with fresh env) plus the injected provider.
97
- function openCodeConfigFor(profile, session) {
98
- const unrestricted = session.permissionMode === 'bypassPermissions';
99
- const editable = unrestricted || session.permissionMode === 'acceptEdits';
100
- const config = { permission: {
101
- '*': 'allow', edit: editable ? 'allow' : 'deny', bash: editable ? 'allow' : 'deny',
102
- external_directory: unrestricted ? 'allow' : 'deny', question: 'deny',
103
- } };
104
- if (isInjected(profile)) {
105
- const endpoint = resolveOpenAIBaseUrl(profile);
106
- if (!endpoint) throw new Error('the selected provider needs an OpenAI base URL for OpenCode');
107
- config.provider = injectedProviderConfig(profile, endpoint);
108
- }
109
- return config;
110
- }
111
-
112
- function injectedProviderConfig(profile, endpoint) {
113
- const model = resolveOpenAIModel(profile);
114
- const entry = profile.models && Array.isArray(profile.models.list)
115
- ? profile.models.list.find(item => item.id === profile.models.default) : null;
116
- return { [PROVIDER_ID]: {
117
- npm: '@ai-sdk/openai-compatible', name: profile.name || 'Agent Terminal',
118
- options: { baseURL: endpoint, apiKey: `{env:${PROVIDER_KEY_ENV}}` },
119
- models: { [model]: { name: model, tool_call: true,
120
- // A custom provider has no models.dev capability entry. Permit
121
- // image transport; the selected endpoint validates model support.
122
- attachment: true, modalities: { input: ['text', 'image'], output: ['text'] },
123
- ...(entry && entry.contextWindow ? { limit: { context: entry.contextWindow, output: 8192 } } : {}),
124
- } },
125
- } };
126
- }
127
-
128
- // ---------------------------------------------------------------------------
129
- // serve transport
130
- // ---------------------------------------------------------------------------
131
-
132
- function createServeOpenCodeDriver(options = {}) {
133
- const command = options.command || 'opencode';
134
- const log = typeof options.log === 'function' ? options.log : () => {};
135
- const sessions = new Map();
136
- const opencodeIds = new Map(); // opencode session id (ses_...) -> driver sessionId
137
- // The CLI fallback runner: `cliSpawn` lets tests script fallback turns
138
- // without `spawn`, which would itself force the CLI transport.
139
- const cliRunner = createCliOpenCodeRunner({ ...options, spawn: options.cliSpawn });
140
- const persist = resolveServePersistence(options);
141
- let serveDiskIndex = null;
142
- let nextId = 1;
143
- let mode = 'serve';
144
- let bridge = null;
145
- let bridgeSpecKey = null;
146
- let bridgeFailures = 0;
147
-
148
- function summary(session) {
149
- return {
150
- sessionId: session.sessionId,
151
- projectSlug: session.projectSlug,
152
- state: session.state,
153
- model: session.model,
154
- selectedModel: session.selectedModel,
155
- aiProfileId: session.aiProfileId,
156
- permissionMode: session.permissionMode,
157
- turns: session.turns,
158
- startedAt: session.startedAt,
159
- // Live activity marker: turn end refreshes it (see turns += 1 site).
160
- updatedAt: session.updatedAt || session.startedAt,
161
- endedAt: session.endedAt,
162
- pendingPermission: session.pendingPermission,
163
- };
164
- }
165
-
166
- function emit(session, event) {
167
- stampEvent(session, event);
168
- session.outputBuffer.push(event);
169
- if (session.outputBuffer.length > MAX_SERVE_PERSISTED_EVENTS) session.outputBuffer.shift();
170
- for (const listener of session.listeners) {
171
- try { listener(event); } catch { /* listener errors never break the turn */ }
172
- }
173
- }
174
-
175
- function setState(session, state) {
176
- session.state = state;
177
- emit(session, { type: 'claude/state', state, turn: session.turns });
178
- }
179
-
180
- function emitDiag(session, event) {
181
- const text = event.text != null ? event.text : event.message;
182
- if (text === session.lastDiag) return;
183
- session.lastDiag = String(text == null ? '' : text);
184
- emit(session, event);
185
- }
186
-
187
- function addUsage(session, usage) {
188
- if (!usage) return;
189
- const input = Number(usage.inputTokens || usage.input || 0) || 0;
190
- const output = Number(usage.outputTokens || usage.output || 0) || 0;
191
- const cached = Number(usage.cachedTokens || (usage.cache && usage.cache.read) || 0) || 0;
192
- session.usage.inputTokens += input;
193
- session.usage.outputTokens += output;
194
- session.usage.cachedTokens += cached;
195
- if (session.turnUsage) {
196
- session.turnUsage.inputTokens += input;
197
- session.turnUsage.outputTokens += output;
198
- session.turnUsage.cachedTokens += cached;
199
- const turnTotal = session.turnUsage.inputTokens + session.turnUsage.outputTokens + session.turnUsage.cachedTokens;
200
- session.peakTurnTokens = Math.max(session.peakTurnTokens || 0, turnTotal);
201
- }
202
- }
203
-
204
- // ---- serve session persistence (shared cli-sessions directory) -----------
205
- // Pure serve sessions never touch the CLI runner, so without this store a
206
- // restart wiped them from the sidebar. Records live beside the CLI ones but
207
- // carry their own schema; the opencode session id (ses_...) is stored as
208
- // threadId so the next turn continues the server-side conversation.
209
-
210
- function serveRecordFile(sessionId) {
211
- return path.join(persist.dir, `${safeFileStem(sessionId)}.json`);
212
- }
213
-
214
- function readServeRecord(file) {
215
- try {
216
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
217
- if (parsed && parsed.schema === SERVE_PERSIST_SCHEMA && parsed.agentId === 'opencode'
218
- && parsed.transport === 'serve' && typeof parsed.sessionId === 'string' && parsed.sessionId) return parsed;
219
- } catch { /* corrupt or foreign records are skipped, never fatal */ }
220
- return null;
221
- }
222
-
223
- function syncServeDiskIndex() {
224
- if (!persist.enabled) return null;
225
- if (!serveDiskIndex) serveDiskIndex = new Map();
226
- else serveDiskIndex.clear();
227
- let names = [];
228
- try { names = fs.readdirSync(persist.dir).filter(name => name.endsWith('.json')); } catch { return serveDiskIndex; }
229
- for (const name of names) {
230
- const file = path.join(persist.dir, name);
231
- const record = readServeRecord(file);
232
- if (record) serveDiskIndex.set(record.sessionId, { file, summary: persistedServeSummary(record) });
233
- }
234
- return serveDiskIndex;
235
- }
236
-
237
- function persistedServeSummary(record) {
238
- return {
239
- sessionId: record.sessionId,
240
- projectSlug: record.projectSlug,
241
- state: 'idle', // a restart killed any live turn
242
- model: record.model || null,
243
- selectedModel: record.selectedModel || null,
244
- aiProfileId: record.aiProfileId || null,
245
- permissionMode: record.permissionMode || 'default',
246
- turns: Number(record.turns) || 0,
247
- startedAt: record.startedAt || '',
248
- updatedAt: record.updatedAt || record.startedAt || '',
249
- endedAt: record.endedAt || null,
250
- pendingPermission: null,
251
- };
252
- }
253
-
254
- function persistServeSession(session) {
255
- if (!persist.enabled) return;
256
- try {
257
- const file = serveRecordFile(session.sessionId);
258
- writeJson(file, {
259
- schema: SERVE_PERSIST_SCHEMA,
260
- agentId: 'opencode',
261
- transport: 'serve',
262
- sessionId: session.sessionId,
263
- projectSlug: session.projectSlug,
264
- projectPath: session.projectPath,
265
- state: session.state,
266
- model: session.model,
267
- selectedModel: session.selectedModel,
268
- aiProfileId: session.aiProfileId,
269
- permissionMode: session.permissionMode,
270
- effort: session.effort || null,
271
- title: session.title || null,
272
- turns: session.turns,
273
- startedAt: session.startedAt,
274
- endedAt: session.endedAt,
275
- // The opencode session id (ses_...): prompting it again resumes the
276
- // server-side conversation after a restart.
277
- threadId: session.opencodeSessionId || null,
278
- lastResult: session.lastResult || '',
279
- usage: { ...session.usage },
280
- peakTurnTokens: session.peakTurnTokens || 0,
281
- eventSequence: Number(session.eventSequence) || 0,
282
- // Old numeric cursors predate this session's numbering era; the
283
- // boundary must survive restarts, not just the first restore.
284
- legacySequenceReset: session.legacySequenceReset === true,
285
- events: session.outputBuffer.slice(-MAX_SERVE_PERSISTED_EVENTS),
286
- updatedAt: new Date().toISOString(),
287
- });
288
- if (serveDiskIndex) serveDiskIndex.set(session.sessionId, { file: serveRecordFile(session.sessionId), summary: summary(session) });
289
- } catch {
290
- // Read-only home or full disk degrades to memory-only behavior.
291
- persist.enabled = false;
292
- serveDiskIndex = null;
293
- }
294
- }
295
-
296
- // Only ever removes OUR record: a CLI mirror may take over the session id.
297
- function removeServeRecord(sessionId) {
298
- if (serveDiskIndex) serveDiskIndex.delete(sessionId);
299
- if (!persist.enabled) return;
300
- try {
301
- const file = serveRecordFile(sessionId);
302
- if (fs.existsSync(file) && readServeRecord(file)) fs.rmSync(file, { force: true });
303
- } catch { /* already gone */ }
304
- }
305
-
306
- function restoreServeSession(id) {
307
- if (!persist.enabled) return null;
308
- const index = syncServeDiskIndex();
309
- const entry = index && index.get(id);
310
- if (!entry) return null;
311
- const record = readServeRecord(entry.file);
312
- if (!record || record.sessionId !== id) { index.delete(id); return null; }
313
- // Legacy records (written before stable event identity) carry unstamped
314
- // events: renumber the retained tail explicitly and continue from there.
315
- const restored = resequenceRestoredEvents(
316
- Array.isArray(record.events) ? record.events.slice(-MAX_SERVE_PERSISTED_EVENTS) : [],
317
- record.eventSequence,
318
- );
319
- const session = {
320
- sessionId: record.sessionId,
321
- projectSlug: record.projectSlug,
322
- projectPath: record.projectPath,
323
- state: 'idle',
324
- model: record.model || null,
325
- selectedModel: record.selectedModel || null,
326
- aiProfileId: record.aiProfileId || null,
327
- permissionMode: record.permissionMode || 'default',
328
- effort: record.effort || null,
329
- title: record.title || null,
330
- turns: Number(record.turns) || 0,
331
- startedAt: record.startedAt || new Date().toISOString(),
332
- updatedAt: record.updatedAt || record.startedAt || new Date().toISOString(),
333
- endedAt: record.endedAt || null,
334
- pendingPermission: null,
335
- outputBuffer: restored.events,
336
- eventSequence: restored.eventSequence,
337
- legacySequenceReset: restored.legacyRenumbered === true || record.legacySequenceReset === true,
338
- listeners: new Set(),
339
- opencodeSessionId: record.threadId || null,
340
- currentTurn: null,
341
- lastResult: record.lastResult || '',
342
- lastDiag: '',
343
- usage: {
344
- inputTokens: Number(record.usage && record.usage.inputTokens) || 0,
345
- outputTokens: Number(record.usage && record.usage.outputTokens) || 0,
346
- cachedTokens: Number(record.usage && record.usage.cachedTokens) || 0,
347
- },
348
- peakTurnTokens: Number(record.peakTurnTokens) || 0,
349
- turnUsage: null,
350
- cliMirrorId: null,
351
- pinnedCli: false,
352
- turnPromise: Promise.resolve(),
353
- };
354
- sessions.set(id, session);
355
- return session;
356
- }
357
-
358
- const runner = {
359
- agentId: 'opencode',
360
-
361
- startChatSession({ slug, projectPath, aiProfile, permissionMode, effort, title }) {
362
- if (!slug) throw new Error('slug required');
363
- if (!projectPath) throw new Error('projectPath required');
364
- const sessionId = nextDriverSessionId();
365
- const session = {
366
- sessionId,
367
- projectSlug: slug,
368
- projectPath,
369
- state: 'idle',
370
- model: cliModelFor(aiProfile),
371
- selectedModel: aiProfile && (aiProfile.mainModel || aiProfile.models && aiProfile.models.default) || '',
372
- aiProfileId: aiProfile && aiProfile.id,
373
- permissionMode: permissionMode || 'default',
374
- effort: effort || null,
375
- title: title || null,
376
- turns: 0,
377
- startedAt: new Date().toISOString(),
378
- endedAt: null,
379
- pendingPermission: null,
380
- outputBuffer: [],
381
- eventSequence: 0,
382
- listeners: new Set(),
383
- opencodeSessionId: null,
384
- currentTurn: null,
385
- lastResult: '',
386
- lastDiag: '',
387
- usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
388
- peakTurnTokens: 0,
389
- turnUsage: null,
390
- cliMirrorId: null,
391
- turnPromise: Promise.resolve(),
392
- };
393
- sessions.set(sessionId, session);
394
- emit(session, {
395
- type: 'claude/session-ready',
396
- model: session.model,
397
- aiProfileId: session.aiProfileId,
398
- permissionMode: session.permissionMode,
399
- message: 'opencode session is ready. Send a message to start the first turn.',
400
- });
401
- persistServeSession(session);
402
- return { sessionId };
403
- },
404
-
405
- listSessions(filter = {}) {
406
- const byId = new Map();
407
- const boundMirrors = new Set();
408
- for (const session of sessions.values()) {
409
- if (filter.projectSlug && session.projectSlug !== filter.projectSlug) continue;
410
- byId.set(session.sessionId, summary(session));
411
- if (session.cliMirrorId) boundMirrors.add(session.cliMirrorId);
412
- }
413
- const extraSources = [cliRunner.listSessions(filter)];
414
- const serveIndex = syncServeDiskIndex();
415
- if (serveIndex) {
416
- extraSources.push([...serveIndex.values()]
417
- .map(entry => entry.summary)
418
- .filter(item => !filter.projectSlug || item.projectSlug === filter.projectSlug));
419
- }
420
- for (const source of extraSources) {
421
- for (const item of source) {
422
- if (byId.has(item.sessionId) || boundMirrors.has(item.sessionId)) continue;
423
- byId.set(item.sessionId, item);
424
- }
425
- }
426
- return [...byId.values()];
427
- },
428
-
429
- getState(id) {
430
- const session = sessions.get(id) || restoreServeSession(id);
431
- if (!session) return cliRunner.getState(id);
432
- return {
433
- ...summary(session),
434
- listenerCount: session.listeners.size,
435
- bufferedEvents: session.outputBuffer.length,
436
- };
437
- },
438
-
439
- getSession(id) { return sessions.get(id) || restoreServeSession(id) || cliRunner.getSession(id); },
440
-
441
- getSessionTokenUsage(id) {
442
- const session = sessions.get(id) || restoreServeSession(id);
443
- if (!session) return cliRunner.getSessionTokenUsage(id);
444
- if (session.cliMirrorId) return cliRunner.getSessionTokenUsage(session.cliMirrorId);
445
- const { inputTokens, outputTokens } = session.usage;
446
- const used = session.peakTurnTokens || 0;
447
- return {
448
- used,
449
- inputTokens,
450
- outputTokens,
451
- hasUsage: Boolean(used || inputTokens || outputTokens),
452
- };
453
- },
454
-
455
- subscribe(id, listener) {
456
- const session = sessions.get(id) || restoreServeSession(id);
457
- if (!session) return cliRunner.subscribe(id, listener);
458
- for (const event of session.outputBuffer) listener(event);
459
- session.listeners.add(listener);
460
- return () => session.listeners.delete(listener);
461
- },
462
-
463
- async sendInput(id, text, profile, sendOptions = {}) {
464
- const session = sessions.get(id) || restoreServeSession(id) || adoptDiskSession(id);
465
- if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
466
- if (ACTIVE_STATES.has(session.state)) {
467
- throw Object.assign(new Error('session already has an active turn'), { status: 409 });
468
- }
469
- const value = String(text || '').trim();
470
- const attachments = normalizeAttachments(sendOptions.attachments || []);
471
- if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
472
- if (profile) {
473
- session.model = cliModelFor(profile);
474
- session.selectedModel = profile.mainModel || profile.models && profile.models.default || '';
475
- }
476
- if (sendOptions.permissionMode) session.permissionMode = sendOptions.permissionMode;
477
- if (sendOptions.effort) session.effort = sendOptions.effort;
478
- session.lastPromptText = value;
479
-
480
- if (mode === 'cli' || session.pinnedCli) {
481
- return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: true });
482
- }
483
-
484
- let prepared = null;
485
- try {
486
- await ensureBridge(profile);
487
- prepared = prepareImages(session.projectPath, attachments);
488
- await ensureOpenCodeSession(session);
489
- } catch (error) {
490
- if (prepared) prepared.cleanup();
491
- if (shouldFallbackToCli(error)) {
492
- switchToCli(error);
493
- return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: true });
494
- }
495
- emit(session, { type: 'claude/error', message: `opencode serve unavailable: ${error.message}`.slice(0, 500) });
496
- setState(session, 'idle');
497
- return { started: false };
498
- }
499
-
500
- emit(session, {
501
- type: 'claude/user-prompt',
502
- text: value,
503
- attachments: (sendOptions.displayAttachments || []).map(({ id, name, mediaType, size, width, height, url }) => ({ id, name, mediaType, size, width, height, url })),
504
- });
505
- setState(session, 'running');
506
- // Until the first serve event lands the UI would show dead air (reasoning
507
- // models think for tens of seconds first) — announce it.
508
- setState(session, 'thinking');
509
- // The turn context must exist BEFORE the prompt POST resolves: the SSE
510
- // stream may already deliver the first events while prompt_async's 204
511
- // is still in flight.
512
- session.lastResult = '';
513
- session.currentTurn = {
514
- assistantMessageID: null,
515
- assistantMessageIDs: new Set(),
516
- userMessageIDs: new Set(), // echoed prompts never stream as output
517
- parts: new Map(), // partID -> tracking state
518
- sawEvent: false,
519
- usageSeen: false,
520
- turnFailed: false,
521
- startedAt: Date.now(),
522
- };
523
- session.turnUsage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
524
- session.turnPromise = new Promise(resolve => { session.currentTurn.resolve = resolve; });
525
- try {
526
- const parts = [
527
- { type: 'text', text: appendTextAttachments(value, attachments) + (sendOptions.knowledgeContext || '') },
528
- ...prepared.imagePaths.map(item => item.part),
529
- ];
530
- await promptAsync(session, parts, profile);
531
- // Images live only for the turn; the serve API reads them eagerly.
532
- {
533
- const turn = session.currentTurn;
534
- const originalResolve = turn.resolve;
535
- turn.resolve = () => { prepared.cleanup(); originalResolve(); };
536
- }
537
- return { started: true };
538
- } catch (error) {
539
- prepared.cleanup();
540
- const turn = session.currentTurn;
541
- session.currentTurn = null;
542
- session.turnUsage = null;
543
- if (turn && turn.resolve) turn.resolve();
544
- if (shouldFallbackToCli(error)) {
545
- switchToCli(error);
546
- return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: false });
547
- }
548
- emit(session, { type: 'claude/error', message: `opencode turn failed to start: ${error.message}`.slice(0, 500) });
549
- setState(session, 'idle');
550
- return { started: false };
551
- }
552
- },
553
-
554
- resolvePermission(id, requestId, decision) {
555
- const session = sessions.get(id) || restoreServeSession(id);
556
- // Neither transport maps opencode permission.asked events; the shared
557
- // CLI runner answers with an honest 501.
558
- return cliRunner.resolvePermission(
559
- session && session.cliMirrorId ? session.cliMirrorId : id, requestId, decision);
560
- },
561
-
562
- updateSelection(id, profile, selectedModel) {
563
- const session = sessions.get(id) || restoreServeSession(id);
564
- if (!session) {
565
- if (cliRunner.getState(id)) return cliRunner.updateSelection(id, profile, selectedModel);
566
- throw Object.assign(new Error('session not found'), { status: 404 });
567
- }
568
- if (ACTIVE_STATES.has(session.state)) {
569
- throw Object.assign(new Error('session already has an active turn'), { status: 409 });
570
- }
571
- const fromAiProfileId = session.aiProfileId || null;
572
- const fromModel = session.selectedModel || session.model || null;
573
- session.aiProfileId = profile && profile.id || null;
574
- session.selectedModel = selectedModel || (profile && (profile.mainModel || profile.models && profile.models.default)) || null;
575
- session.model = cliModelFor(profile);
576
- if (!session.cliMirrorId) {
577
- emit(session, {
578
- type: 'claude/selection-changed',
579
- fromAiProfileId,
580
- toAiProfileId: session.aiProfileId,
581
- fromModel,
582
- toModel: session.selectedModel,
583
- });
584
- }
585
- if (session.cliMirrorId) {
586
- try { cliRunner.updateSelection(session.cliMirrorId, profile, selectedModel); } catch { /* mirror may be mid-turn */ }
587
- } else {
588
- persistServeSession(session);
589
- }
590
- return summary(session);
591
- },
592
-
593
- abort(id, reason) {
594
- const session = sessions.get(id) || restoreServeSession(id);
595
- if (!session) return cliRunner.abort(id, reason);
596
- session.abortedByUser = true;
597
- if (mode === 'serve' && session.currentTurn && session.opencodeSessionId && bridge && bridge.state === 'ready') {
598
- // Preferred path: per-session abort on the shared serve instance
599
- // (verified: POST /session/{id}/abort interrupts only that session).
600
- bridge.request('POST', `/session/${session.opencodeSessionId}/abort`, { timeoutMs: 5000 })
601
- .catch(() => log(`opencode abort endpoint failed for ${session.opencodeSessionId}`));
602
- }
603
- if (session.currentTurn) {
604
- session.state = 'aborted';
605
- emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
606
- finishServeTurn(session, {});
607
- }
608
- if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId, reason);
609
- if (!['idle', 'ended'].includes(session.state)) {
610
- session.state = 'aborted';
611
- emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
612
- setState(session, 'idle');
613
- }
614
- if (!session.cliMirrorId) persistServeSession(session);
615
- },
616
-
617
- deleteSession(id) {
618
- const session = sessions.get(id) || restoreServeSession(id);
619
- if (!session) {
620
- const index = syncServeDiskIndex();
621
- if (index && index.has(id)) { removeServeRecord(id); return true; }
622
- return cliRunner.deleteSession(id);
623
- }
624
- if (ACTIVE_STATES.has(session.state)) runner.abort(id);
625
- if (session.opencodeSessionId) {
626
- opencodeIds.delete(session.opencodeSessionId);
627
- if (mode === 'serve' && bridge && bridge.state === 'ready') {
628
- bridge.request('DELETE', `/session/${session.opencodeSessionId}`).catch(() => {});
629
- }
630
- }
631
- const deleted = sessions.delete(id);
632
- if (session.cliMirrorId) cliRunner.deleteSession(session.cliMirrorId);
633
- removeServeRecord(id);
634
- return deleted;
635
- },
636
-
637
- async listSupportedCommands() { return []; },
638
-
639
- findClaudeExecutableForSdk() { return null; },
640
-
641
- // Test seams.
642
- get transportMode() { return mode; },
643
- get bridgeState() { return bridge ? bridge.state : 'uncreated'; },
644
- };
645
-
646
- // -- bridge lifecycle ----------------------------------------------------
647
-
648
- function bridgeSpecFor(profile) {
649
- if (!isInjected(profile)) return { key: 'agent-owned', env: {} };
650
- const endpoint = resolveOpenAIBaseUrl(profile); // throws without a base URL
651
- const model = resolveOpenAIModel(profile);
652
- return {
653
- key: `injected:${endpoint}`,
654
- env: {
655
- OPENCODE_CONFIG_CONTENT: JSON.stringify({ provider: injectedProviderConfig(profile, endpoint) }),
656
- [PROVIDER_KEY_ENV]: profile.apiKey,
657
- },
658
- };
659
- }
660
-
661
- async function ensureBridge(profile) {
662
- const spec = bridgeSpecFor(profile); // invalid profiles throw here (no fallback)
663
- if (bridge && bridge.state === 'closed') { bridge = null; bridgeSpecKey = null; }
664
- if (bridge && bridge.state === 'crashed') recycleBridge('bridge exhausted its restart budget');
665
- if (bridge && bridgeSpecKey !== spec.key) recycleBridge('provider spec changed');
666
- if (!bridge) {
667
- bridge = createOpenCodeServerBridge({
668
- command,
669
- env: spec.env,
670
- spawn: options.serveSpawn,
671
- fetch: options.serveFetch,
672
- ...(options.allocatePort !== undefined ? { allocatePort: options.allocatePort } : {}),
673
- onEvent: handleServeEvent,
674
- onSseGap: reconcileSseGap,
675
- onUnexpectedExit: handleBridgeCrash,
676
- onReady: () => { bridgeFailures = 0; },
677
- healthTimeoutMs: options.healthTimeoutMs,
678
- idleTimeoutMs: options.idleTimeoutMs !== undefined ? options.idleTimeoutMs : 10 * 60 * 1000,
679
- requestTimeoutMs: options.requestTimeoutMs,
680
- restartBackoffMs: options.restartBackoffMs,
681
- sseReconnectMs: options.sseReconnectMs,
682
- heartbeatTimeoutMs: options.heartbeatTimeoutMs,
683
- log,
684
- });
685
- bridgeSpecKey = spec.key;
686
- }
687
- await bridge.start();
688
- return bridge;
689
- }
690
-
691
- // The bridge is going away (crash, recycle, transport switch): every live
692
- // turn finishes with an error because its end signal will never arrive.
693
- // (finishServeTurn owns nulling currentTurn and resolving the turn promise.)
694
- function failoverSessions(reason) {
695
- for (const session of sessions.values()) {
696
- if (!session.currentTurn) continue;
697
- finishServeTurn(session, { error: String(reason).slice(0, 500) });
698
- }
699
- }
700
-
701
- function recycleBridge(reason) {
702
- log(`recycling opencode serve bridge: ${reason}`);
703
- if (bridge) bridge.close().catch(() => {});
704
- bridge = null;
705
- bridgeSpecKey = null;
706
- failoverSessions(`opencode serve exited: ${reason}`);
707
- }
708
-
709
- function handleBridgeCrash(error) {
710
- if (mode !== 'serve') return;
711
- failoverSessions(`opencode serve exited unexpectedly: ${error && error.message || 'crashed'}`);
712
- bridgeFailures += 1;
713
- if (bridgeFailures >= 2) switchToCli(error);
714
- }
715
-
716
- function switchToCli(error) {
717
- if (mode === 'cli') return;
718
- mode = 'cli';
719
- recycleBridge(`falling back to opencode CLI: ${error && error.message}`);
720
- for (const session of sessions.values()) {
721
- emitDiag(session, {
722
- type: 'claude/stderr',
723
- text: `opencode serve unavailable (${String(error && error.message || error).slice(0, 200)}); using opencode run fallback`,
724
- });
725
- }
726
- }
727
-
728
- function shouldFallbackToCli(error) {
729
- if (!error) return false;
730
- if (error.code === 'BRIDGE_SPAWN_FAILED') return true; // ENOENT / early exit
731
- if (error.code === 'BRIDGE_HEALTH_TIMEOUT') return true; // handshake failed
732
- if (error.code === 'BRIDGE_CRASHED' || error.code === 'BRIDGE_CLOSED') return true;
733
- if (error.code === 'BRIDGE_REQUEST_FAILED') return true; // connection refused: child gone
734
- return false;
735
- }
736
-
737
- // -- serve session lifecycle ----------------------------------------------
738
-
739
- // A model ref for prompt_async: injected profiles address the custom
740
- // provider; agent-owned models use the 'provider/model' convention when the
741
- // profile carries one, otherwise the server default applies.
742
- function modelRefFor(session, profile) {
743
- if (isInjected(profile)) {
744
- const model = resolveOpenAIModel(profile);
745
- return model ? { providerID: PROVIDER_ID, modelID: model } : null;
746
- }
747
- const model = session.model;
748
- if (!model || !model.includes('/')) return null;
749
- const slash = model.indexOf('/');
750
- const providerID = model.slice(0, slash);
751
- const modelID = model.slice(slash + 1);
752
- return providerID && modelID ? { providerID, modelID } : null;
753
- }
754
-
755
- async function ensureOpenCodeSession(session) {
756
- if (session.opencodeSessionId) {
757
- // Restored records carry their id but not the routing entry (the map
758
- // died with the previous process); rebind before the turn starts.
759
- opencodeIds.set(session.opencodeSessionId, session.sessionId);
760
- return;
761
- }
762
- const created = await bridge.request('POST', `/session?directory=${encodeURIComponent(session.projectPath)}`, {
763
- body: { permission: permissionRulesetFor(session.permissionMode) },
764
- timeoutMs: 30000,
765
- });
766
- const id = created && created.id;
767
- if (!id) throw new Error('opencode serve did not return a session id');
768
- session.opencodeSessionId = id;
769
- opencodeIds.set(id, session.sessionId);
770
- if (created.title && !session.title) session.title = String(created.title).slice(0, 200);
771
- persistServeSession(session);
772
- }
773
-
774
- async function promptAsync(session, parts, profile) {
775
- const body = {
776
- parts,
777
- ...(modelRefFor(session, profile) ? { model: modelRefFor(session, profile) } : {}),
778
- ...(session.permissionMode === 'plan' ? { agent: 'plan' } : {}),
779
- };
780
- try {
781
- await bridge.request('POST', `/session/${session.opencodeSessionId}/prompt_async`, {
782
- body, timeoutMs: 30000,
783
- });
784
- } catch (error) {
785
- if (error && error.code === 'BRIDGE_HTTP_ERROR' && error.httpStatus === 404) {
786
- // The opencode session was rolled away (server db prune); continue in
787
- // a fresh one rather than failing the turn.
788
- log(`opencode session ${session.opencodeSessionId} vanished; creating a new one`);
789
- opencodeIds.delete(session.opencodeSessionId);
790
- session.opencodeSessionId = null;
791
- await ensureOpenCodeSession(session);
792
- await bridge.request('POST', `/session/${session.opencodeSessionId}/prompt_async`, {
793
- body, timeoutMs: 30000,
794
- });
795
- return;
796
- }
797
- throw error;
798
- }
799
- }
800
-
801
- // -- serve event mapping ---------------------------------------------------
802
-
803
- function routeServeSession(opencodeSessionId) {
804
- if (!opencodeSessionId) return null;
805
- const driverId = opencodeIds.get(opencodeSessionId);
806
- return driverId ? sessions.get(driverId) : null;
807
- }
808
-
809
- function markActivity(session) {
810
- const turn = session.currentTurn;
811
- if (!turn) return;
812
- if (!turn.sawEvent) {
813
- turn.sawEvent = true;
814
- if (session.state === 'thinking') setState(session, 'running');
815
- }
816
- }
817
-
818
- function partTracker(session, partID) {
819
- const turn = session.currentTurn;
820
- if (!turn || !partID) return null;
821
- if (!turn.parts.has(partID)) {
822
- turn.parts.set(partID, { type: null, rawLen: 0, emittedText: '', announced: false, toolDone: false, counted: false });
823
- }
824
- return turn.parts.get(partID);
825
- }
826
-
827
- function handleServeEvent(payload) {
828
- const type = payload && payload.type;
829
- const properties = (payload && payload.properties) || {};
830
- const session = routeServeSession(properties.sessionID);
831
- if (!session) return; // events for unknown sessions are ignored
832
- switch (type) {
833
- case 'session.created':
834
- case 'session.updated': return onSessionUpdated(session, properties);
835
- case 'session.status': return onSessionStatus(session, properties);
836
- case 'session.idle': return onSessionIdle(session);
837
- case 'session.error': return onSessionError(session, properties);
838
- case 'message.updated': return onMessageUpdated(session, properties);
839
- case 'message.part.updated': return onMessagePartUpdated(session, properties);
840
- case 'message.part.delta': return onMessagePartDelta(session, properties);
841
- default: return; // sync, message.removed, session.diff, permission.asked, ...
842
- }
843
- }
844
-
845
- function onSessionUpdated(session, { info }) {
846
- if (info && info.title && !session.title) {
847
- session.title = String(info.title).slice(0, 200);
848
- if (!session.cliMirrorId) persistServeSession(session);
849
- }
850
- }
851
-
852
- function onSessionStatus(session, { status }) {
853
- if (status && status.type === 'busy') markActivity(session);
854
- }
855
-
856
- function onSessionError(session, { error }) {
857
- markActivity(session);
858
- const message = String((error && (error.message || error.name)) || error || 'opencode session error').slice(0, 500);
859
- emitDiag(session, { type: 'claude/error', message });
860
- if (session.currentTurn) session.currentTurn.turnFailed = true;
861
- }
862
-
863
- function onMessageUpdated(session, { info }) {
864
- if (!info || !session.currentTurn) return;
865
- if (info.role === 'user') {
866
- // Echoed user messages (this turn's prompt and trailing re-updates of
867
- // previous ones) must never stream as assistant output.
868
- if (info.id) session.currentTurn.userMessageIDs.add(info.id);
869
- return;
870
- }
871
- if (info.role !== 'assistant') return;
872
- const turn = session.currentTurn;
873
- if (info.id && !turn.assistantMessageIDs.has(info.id) && info.time?.created && info.time.created < turn.startedAt - 5000) return;
874
- markActivity(session);
875
- // Tool rounds and the final answer have distinct assistant message IDs.
876
- if (info.id) { turn.assistantMessageIDs.add(info.id); turn.assistantMessageID = info.id; }
877
- // Completed assistant messages carry the final token totals; they back
878
- // up the per-step-finish usage when a turn had no step-finish parts.
879
- if (info.time && info.time.completed && info.tokens && info.tokens.total != null) {
880
- session.currentTurn.messageTokens = info.tokens;
881
- }
882
- }
883
-
884
- // Parts of echoed user prompts (or stale messages) must not leak into the
885
- // assistant stream; the turn tracks every user message id it has seen and
886
- // the assistant message this turn belongs to.
887
- function partBelongsToTurn(turn, messageID) {
888
- if (messageID && turn.userMessageIDs.has(messageID)) return false;
889
- if (messageID && turn.assistantMessageIDs.has(messageID)) return true;
890
- if (turn.assistantMessageID && messageID && messageID !== turn.assistantMessageID) return false;
891
- return true;
892
- }
893
-
894
- function onMessagePartUpdated(session, { part }) {
895
- if (!part || !session.currentTurn) return;
896
- if (!partBelongsToTurn(session.currentTurn, part.messageID)) return;
897
- if (!session.currentTurn.assistantMessageID && part.messageID) {
898
- session.currentTurn.assistantMessageID = part.messageID;
899
- session.currentTurn.assistantMessageIDs.add(part.messageID);
900
- }
901
- markActivity(session);
902
- if (part.type === 'step-start') {
903
- partTracker(session, part.id); // mark seen: reconcile must not replay it
904
- // Step boundaries flush any carried partial think tag as literal text
905
- // and reset the parse context — identical to the CLI transport.
906
- for (const piece of flushThinkCarry(session)) emitServeThinkPiece(session, piece, part);
907
- resetThinkState(session);
908
- return;
909
- }
910
- if (part.type === 'step-finish') {
911
- const tracker = partTracker(session, part.id);
912
- if (tracker && tracker.counted) return; // reconciled replay: usage counted once
913
- if (tracker) tracker.counted = true;
914
- const tokens = part.tokens || {};
915
- addUsage(session, { inputTokens: tokens.input, outputTokens: tokens.output, cachedTokens: tokens.cache && tokens.cache.read });
916
- session.currentTurn.usageSeen = true;
917
- emit(session, {
918
- type: 'claude/usage',
919
- usage: { input_tokens: Number(tokens.input || 0), output_tokens: Number(tokens.output || 0) },
920
- });
921
- return;
922
- }
923
- if (part.type === 'text') {
924
- const tracker = partTracker(session, part.id);
925
- if (!tracker) return;
926
- tracker.type = 'text';
927
- emitServeTextSuffix(session, tracker, part, String(part.text || ''));
928
- return;
929
- }
930
- if (part.type === 'reasoning') {
931
- const tracker = partTracker(session, part.id);
932
- if (!tracker) return;
933
- tracker.type = 'reasoning';
934
- const text = String(part.text || '');
935
- if (text.length > tracker.emittedText.length) {
936
- const suffix = text.slice(tracker.emittedText.length);
937
- tracker.emittedText = text;
938
- if (!tracker.announced) {
939
- tracker.announced = true;
940
- emit(session, { type: 'claude/thinking-start', id: part.id || part.messageID });
941
- }
942
- emit(session, { type: 'claude/thinking-delta', id: part.id || part.messageID, text: suffix });
943
- }
944
- return;
945
- }
946
- if (part.type === 'tool') return onToolPart(session, part);
947
- }
948
-
949
- // Tool parts arrive as state snapshots pending -> running -> completed; the
950
- // first snapshot with input announces the tool-use, the terminal status
951
- // emits the result. Live output grows on the running snapshots.
952
- function onToolPart(session, part) {
953
- const tracker = partTracker(session, part.id || part.callID);
954
- if (!tracker) return;
955
- const state = part.state || {};
956
- const id = part.callID || part.id;
957
- if (!tracker.announced && (state.input && Object.keys(state.input).length || state.status === 'running' || state.status === 'completed')) {
958
- tracker.announced = true;
959
- setState(session, 'tool-running');
960
- emit(session, {
961
- type: 'claude/tool-use',
962
- id,
963
- name: part.tool || 'tool',
964
- input: state.input || {},
965
- });
966
- }
967
- if (!tracker.toolDone && (state.status === 'completed' || state.status === 'error')) {
968
- tracker.toolDone = true;
969
- setState(session, 'running');
970
- const output = state.status === 'error'
971
- ? String(state.error || state.output || 'tool failed')
972
- : String(state.output != null ? state.output : '');
973
- emit(session, {
974
- type: 'claude/tool-result',
975
- id,
976
- output: output.slice(0, 4000),
977
- isError: state.status === 'error',
978
- ...(state.metadata && state.metadata.diff !== undefined ? { diff: state.metadata.diff } : {}),
979
- });
980
- }
981
- }
982
-
983
- // True streaming deltas (field 'text' on text parts). The delta runs
984
- // through the same <think> stripping state machine as the CLI transport.
985
- function onMessagePartDelta(session, { partID, messageID, field, delta }) {
986
- const turn = session.currentTurn;
987
- if (!turn || field !== 'text' || !partID) return;
988
- if (!partBelongsToTurn(turn, messageID)) return;
989
- if (!turn.assistantMessageID && messageID) { turn.assistantMessageID = messageID; turn.assistantMessageIDs.add(messageID); }
990
- markActivity(session);
991
- const tracker = partTracker(session, partID);
992
- if (!tracker) return;
993
- if (tracker.type === null) tracker.type = 'text'; // deltas precede the snapshot sometimes
994
- if (tracker.type === 'reasoning') {
995
- if (!tracker.announced) {
996
- tracker.announced = true;
997
- emit(session, { type: 'claude/thinking-start', id: partID });
998
- }
999
- tracker.emittedText += String(delta || '');
1000
- emit(session, { type: 'claude/thinking-delta', id: partID, text: String(delta || '') });
1001
- return;
1002
- }
1003
- emitServeTextDelta(session, tracker, partID, String(delta || ''));
1004
- }
1005
-
1006
- // Feeds one text delta through the think-strip state machine and emits the
1007
- // ordered thinking/text events (mirror of the CLI parseOpenCodeLine path).
1008
- // `rawLen` counts the RAW characters consumed (deltas and snapshots speak
1009
- // the same raw text; only the emitted pieces are stripped).
1010
- function emitServeTextDelta(session, tracker, partID, delta) {
1011
- tracker.rawLen += delta.length;
1012
- const pieces = parseThinkPieces(delta, thinkStateOf(session));
1013
- for (const piece of pieces) {
1014
- if (!piece.text) continue;
1015
- if (piece.think) {
1016
- if (thinkStateOf(session).emittedBlock !== piece.block) {
1017
- emit(session, { type: 'claude/thinking-start', id: partID });
1018
- thinkStateOf(session).emittedBlock = piece.block;
1019
- }
1020
- emit(session, { type: 'claude/thinking-delta', id: partID, text: piece.text });
1021
- continue;
1022
- }
1023
- if (!tracker.announced) {
1024
- tracker.announced = true;
1025
- emit(session, { type: 'claude/text-start', id: partID });
1026
- }
1027
- emit(session, { type: 'claude/text-delta', id: partID, text: piece.text });
1028
- session.lastResult += piece.text;
1029
- }
1030
- }
1031
-
1032
- function emitServeThinkPiece(session, piece, part) {
1033
- if (!piece || !piece.text) return;
1034
- if (piece.think) {
1035
- emit(session, { type: 'claude/thinking-start', id: part.messageID || 'think' });
1036
- emit(session, { type: 'claude/thinking-delta', id: part.messageID || 'think', text: piece.text });
1037
- return;
1038
- }
1039
- emit(session, { type: 'claude/text-start', id: part.messageID || 'text' });
1040
- emit(session, { type: 'claude/text-delta', id: part.messageID || 'text', text: piece.text });
1041
- session.lastResult += piece.text;
1042
- }
1043
-
1044
- // Snapshot recovery: emits the not-yet-emitted suffix of a text part. With
1045
- // healthy deltas this is a no-op; after an SSE gap (or on servers that only
1046
- // send snapshots) it fills the hole with the accumulated text.
1047
- function emitServeTextSuffix(session, tracker, part, snapshotText) {
1048
- if (snapshotText.length <= tracker.rawLen) return;
1049
- const suffix = snapshotText.slice(tracker.rawLen);
1050
- emitServeTextDelta(session, tracker, part.id, suffix);
1051
- }
1052
-
1053
- function onSessionIdle(session) {
1054
- const turn = session.currentTurn;
1055
- if (!turn) return;
1056
- const failed = turn.turnFailed;
1057
- if (!turn.usageSeen && turn.messageTokens) {
1058
- // Step-finish usage never arrived; the assistant message carries it.
1059
- const tokens = turn.messageTokens;
1060
- addUsage(session, { inputTokens: tokens.input, outputTokens: tokens.output, cachedTokens: tokens.cache && tokens.cache.read });
1061
- turn.usageSeen = true;
1062
- emit(session, {
1063
- type: 'claude/usage',
1064
- usage: { input_tokens: Number(tokens.input || 0), output_tokens: Number(tokens.output || 0) },
1065
- });
1066
- }
1067
- finishServeTurn(session, { error: failed ? 'opencode turn failed' : null });
1068
- }
1069
-
1070
- function finishServeTurn(session, { error = null } = {}) {
1071
- const turn = session.currentTurn;
1072
- session.currentTurn = null;
1073
- session.turns += 1;
1074
- session.updatedAt = new Date().toISOString();
1075
- if (error) {
1076
- emitDiag(session, { type: 'claude/error', message: String(error).slice(0, 500) });
1077
- }
1078
- emit(session, {
1079
- type: 'claude/result',
1080
- result: error ? '' : session.lastResult,
1081
- model: session.model,
1082
- isError: Boolean(error),
1083
- });
1084
- session.abortedByUser = false;
1085
- session.turnUsage = null;
1086
- if (!session.endedAt) session.state = 'idle';
1087
- emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
1088
- if (!session.cliMirrorId) persistServeSession(session);
1089
- // A completed turn proves the bridge is healthy again.
1090
- if (!error) bridgeFailures = 0;
1091
- if (turn && turn.resolve) turn.resolve();
1092
- }
1093
-
1094
- // The SSE stream dropped mid-flight: missed deltas are backfilled by the
1095
- // snapshot-diff on the next part updates; a turn whose END fell into the
1096
- // gap is detected by refetching the message list (a completed assistant
1097
- // message newer than the turn start finishes the turn).
1098
- function reconcileSseGap() {
1099
- if (mode !== 'serve' || !bridge || bridge.state !== 'ready') return;
1100
- for (const session of sessions.values()) {
1101
- const turn = session.currentTurn;
1102
- if (!turn || !session.opencodeSessionId) continue;
1103
- bridge.request('GET', `/session/${session.opencodeSessionId}/message`, { timeoutMs: 15000 })
1104
- .then(messages => {
1105
- if (!Array.isArray(messages) || !session.currentTurn || session.currentTurn !== turn) return;
1106
- let completed = null;
1107
- for (const message of messages) {
1108
- const info = message && message.info;
1109
- if (!info || info.role !== 'assistant' || !info.time || !info.time.completed) continue;
1110
- if (info.time.completed < turn.startedAt - 5000) continue;
1111
- if (!completed || info.time.completed > completed.info.time.completed) completed = message;
1112
- }
1113
- if (!completed) return;
1114
- // Re-feed the parts through the snapshot handlers. Text and tool
1115
- // parts are idempotent through the emitted-length/status trackers;
1116
- // step boundaries already seen are skipped so their usage is never
1117
- // counted twice and the think state is not reset mid-block.
1118
- for (const part of completed.parts || []) {
1119
- if (!part || !part.id) continue;
1120
- const known = turn.parts.has(part.id);
1121
- if (known && (part.type === 'step-start' || part.type === 'step-finish')) continue;
1122
- onMessagePartUpdated(session, { part });
1123
- }
1124
- if (session.currentTurn === turn) onSessionIdle(session);
1125
- })
1126
- .catch(error => log(`opencode gap reconciliation failed: ${error.message}`));
1127
- }
1128
- }
1129
-
1130
- // -- CLI fallback plumbing ------------------------------------------------
1131
-
1132
- // The CLI fallback runs inside a mirror session of the internal CLI runner;
1133
- // its events are re-emitted on the owning serve session (see codex-driver).
1134
- function attachMirror(session) {
1135
- if (session.mirrorAttached) return;
1136
- session.mirrorAttached = true;
1137
- cliRunner.subscribe(session.cliMirrorId, event => {
1138
- if (event.type === 'claude/session-ready'
1139
- && session.outputBuffer.some(item => item.type === 'claude/session-ready')) return;
1140
- if (event.type === 'claude/user-prompt' && session.skipNextMirrorPrompt) {
1141
- session.skipNextMirrorPrompt = false;
1142
- return;
1143
- }
1144
- if (event.type === 'claude/state') {
1145
- session.state = event.state;
1146
- if (typeof event.turn === 'number') session.turns = event.turn;
1147
- }
1148
- emit(session, event);
1149
- });
1150
- }
1151
-
1152
- async function runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt }) {
1153
- attachments = attachments || [];
1154
- if (!session.cliMirrorId) {
1155
- removeServeRecord(session.sessionId);
1156
- const { sessionId: mirrorId } = cliRunner.startChatSession({
1157
- slug: session.projectSlug,
1158
- projectPath: session.projectPath,
1159
- aiProfile: { id: session.aiProfileId, models: { default: session.selectedModel || '' } },
1160
- permissionMode: session.permissionMode,
1161
- effort: session.effort,
1162
- title: session.title,
1163
- });
1164
- session.cliMirrorId = mirrorId;
1165
- // An opencode session id (from live turns or a restored record) is the
1166
- // same id `opencode run --session` resumes, so the mirror inherits it.
1167
- if (session.opencodeSessionId) {
1168
- const mirror = cliRunner.getSession(mirrorId);
1169
- if (mirror && !mirror.threadId) mirror.threadId = session.opencodeSessionId;
1170
- }
1171
- session.skipNextMirrorPrompt = !emitPrompt;
1172
- attachMirror(session);
1173
- } else if (!emitPrompt) {
1174
- session.skipNextMirrorPrompt = true;
1175
- }
1176
- const result = await cliRunner.sendInput(session.cliMirrorId, value, profile, {
1177
- ...sendOptions,
1178
- attachments: sendOptions.attachments,
1179
- });
1180
- const mirror = cliRunner.getSession(session.cliMirrorId);
1181
- if (mirror) session.turnPromise = mirror.turnPromise;
1182
- return result;
1183
- }
1184
-
1185
- // Adopt a session that only exists in the CLI runner's store: the driver
1186
- // session takes the SAME id so the sidebar handle stays stable and every
1187
- // future turn stays pinned to the CLI transport.
1188
- function adoptDiskSession(id) {
1189
- const mirror = cliRunner.getSession(id);
1190
- if (!mirror) return null;
1191
- const session = {
1192
- sessionId: id,
1193
- projectSlug: mirror.projectSlug,
1194
- projectPath: mirror.projectPath,
1195
- state: 'idle',
1196
- model: mirror.model || null,
1197
- selectedModel: mirror.selectedModel || null,
1198
- aiProfileId: mirror.aiProfileId || null,
1199
- permissionMode: mirror.permissionMode || 'default',
1200
- effort: mirror.effort || null,
1201
- title: mirror.title || null,
1202
- turns: Number(mirror.turns) || 0,
1203
- startedAt: mirror.startedAt || new Date().toISOString(),
1204
- endedAt: mirror.endedAt || null,
1205
- pendingPermission: null,
1206
- outputBuffer: [],
1207
- eventSequence: 0,
1208
- listeners: new Set(),
1209
- opencodeSessionId: null, // the CLI thread id lives on the mirror
1210
- currentTurn: null,
1211
- lastResult: mirror.lastResult || '',
1212
- lastDiag: '',
1213
- usage: {
1214
- inputTokens: mirror.usage ? mirror.usage.inputTokens : 0,
1215
- outputTokens: mirror.usage ? mirror.usage.outputTokens : 0,
1216
- cachedTokens: mirror.usage ? mirror.usage.cachedTokens : 0,
1217
- },
1218
- peakTurnTokens: mirror.peakTurnTokens || 0,
1219
- turnUsage: null,
1220
- cliMirrorId: id,
1221
- mirrorAttached: false,
1222
- pinnedCli: true,
1223
- turnPromise: Promise.resolve(),
1224
- };
1225
- sessions.set(id, session);
1226
- attachMirror(session);
1227
- return session;
1228
- }
1229
-
1230
- function nextDriverSessionId() {
1231
- const taken = new Set(sessions.keys());
1232
- for (const item of cliRunner.listSessions()) taken.add(item.sessionId);
1233
- const index = syncServeDiskIndex();
1234
- if (index) for (const id of index.keys()) taken.add(id);
1235
- let id = `opencode-${nextId++}`;
1236
- while (taken.has(id)) id = `opencode-${nextId++}`;
1237
- return id;
1238
- }
1239
-
1240
- return runner;
1241
- }
1242
-
1243
- function cliModelFor(profile) {
1244
- const model = resolveOpenAIModel(profile);
1245
- if (!model) return null;
1246
- return /^[A-Za-z0-9._:/-]+$/.test(model) ? `${isInjected(profile) ? `${PROVIDER_ID}/` : ''}${model}` : null;
1247
- }
1248
-
1249
- // Workbench permission modes -> opencode per-session permission ruleset
1250
- // (verified live against 1.18.5: a denied permission removes the tool from
1251
- // the model's toolset, an allowed one permits it without asking).
1252
- function permissionRulesetFor(permissionMode) {
1253
- const unrestricted = permissionMode === 'bypassPermissions';
1254
- const editable = unrestricted || permissionMode === 'acceptEdits';
1255
- return [
1256
- { permission: '*', pattern: '*', action: 'allow' },
1257
- { permission: 'edit', pattern: '*', action: editable ? 'allow' : 'deny' },
1258
- { permission: 'bash', pattern: '*', action: editable ? 'allow' : 'deny' },
1259
- { permission: 'external_directory', pattern: '*', action: unrestricted ? 'allow' : 'deny' },
1260
- { permission: 'question', pattern: '*', action: 'deny' },
1261
- ];
1262
- }
1263
-
1264
- function parseOpenCodeLine(event, session, helpers) {
1265
- const out = [];
1266
- const type = event && event.type;
1267
- const part = (event && event.part) || {};
1268
- if (event && event.sessionID) helpers.setThreadId(event.sessionID);
1269
- // Step boundaries flush any carried partial tag as literal text and start a
1270
- // fresh parse context: a think block left open by a previous step (or turn)
1271
- // must never swallow this step's text.
1272
- if (type === 'step_start' || type === 'step_finish') {
1273
- emitThinkTextPieces(flushThinkCarry(session), part, session, helpers, out);
1274
- if (type === 'step_start') {
1275
- resetThinkState(session);
1276
- return out;
1277
- }
1278
- }
1279
-
1280
- if (type === 'text' && part.text) {
1281
- emitThinkTextPieces(parseThinkPieces(part.text, thinkStateOf(session)), part, session, helpers, out);
1282
- return out;
1283
- }
1284
- if (type === 'reasoning' && part.text) {
1285
- out.push({ type: 'claude/thinking-start', id: part.messageID || `think-${session.turns}` });
1286
- out.push({ type: 'claude/thinking-delta', id: part.messageID || `think-${session.turns}`, text: part.text });
1287
- return out;
1288
- }
1289
- if (type === 'tool_start' || type === 'tool_start.begin') {
1290
- helpers.setState(session, 'tool-running');
1291
- out.push({
1292
- type: 'claude/tool-use',
1293
- id: part.id || part.toolCallID || `tool-${session.outputBuffer.length}`,
1294
- name: part.tool || 'tool',
1295
- input: (part.state && part.state.input) || part.input || {},
1296
- });
1297
- return out;
1298
- }
1299
- if (type === 'tool_end' || type === 'tool_end.finish') {
1300
- const output = (part.state && part.state.output) || part.output || '';
1301
- out.push({
1302
- type: 'claude/tool-result',
1303
- id: part.id || part.toolCallID || `tool-${session.outputBuffer.length}`,
1304
- output: String(output).slice(0, 4000),
1305
- ...(part.state && part.state.metadata ? { metadata: part.state.metadata, diff: part.state.metadata.diff } : {}),
1306
- });
1307
- return out;
1308
- }
1309
- // opencode 1.18 emits one completed `tool_use` record per tool call.
1310
- if (type === 'tool_use') {
1311
- const state = part.state || {};
1312
- const id = part.callID || part.id || `tool-${session.outputBuffer.length}`;
1313
- helpers.setState(session, 'tool-running');
1314
- out.push({ type: 'claude/tool-use', id, name: part.tool || 'tool', input: state.input || part.input || {} });
1315
- if (state.status === 'completed' || state.status === 'error' || state.output != null) {
1316
- out.push({ type: 'claude/tool-result', id, output: String(state.output || state.error || '').slice(0, 4000),
1317
- isError: state.status === 'error', metadata: state.metadata, diff: state.metadata && state.metadata.diff });
1318
- }
1319
- return out;
1320
- }
1321
- if (type === 'step_finish') {
1322
- const tokens = part.tokens || {};
1323
- helpers.addUsage({ inputTokens: tokens.input, outputTokens: tokens.output, cachedTokens: tokens.cache && tokens.cache.read });
1324
- // Step boundaries are not turn boundaries: the runner synthesizes the
1325
- // single turn-level claude/result at process close.
1326
- out.push({
1327
- type: 'claude/usage',
1328
- usage: { input_tokens: Number(tokens.input || 0), output_tokens: Number(tokens.output || 0) },
1329
- });
1330
- return out;
1331
- }
1332
- if (type === 'error') {
1333
- const error = event.error || part.error;
1334
- out.push({ type: 'claude/error', message: String(event.message || (error && error.data && error.data.message) || (error && error.message) || error || 'opencode error').slice(0, 500) });
1335
- return out;
1336
- }
1337
- return out; // step_start and unknown types are ignored
1338
- }
1339
-
1340
- function isInjected(profile) { return Boolean(profile && profile.apiKey); }
1341
-
1342
- // --- <think> stripping ------------------------------------------------------
1343
- // OpenAI-compatible models routed through opencode (MiniMax and friends) wrap
1344
- // their chain-of-thought in literal <think>…</think> inside text parts, which
1345
- // used to land raw in the reply. Strip it session-wide: a think block can span
1346
- // several parts, so the in/out state lives on the session; think content is
1347
- // re-emitted as thinking events (the UI already folds those) and everything
1348
- // outside flows through the normal text path.
1349
- //
1350
- // Text parts are incremental deltas, so a tag can be SPLIT across parts
1351
- // ('</thi' + 'nk>'): a trailing partial tag is held back in state.carry and
1352
- // re-joined with the next part. Step boundaries flush an unresolved carry as
1353
- // literal text instead of dropping it. The serve transport feeds
1354
- // message.part.delta events through the exact same state machine.
1355
- const THINK_OPEN_TEXT = '<think>';
1356
- const THINK_CLOSE_TEXT = '</think>';
1357
- // Tolerate whitespace inside the tag ('</think >') — models emit it.
1358
- const THINK_CLOSE_TAG = /<\/think\s*>/;
1359
- const THINK_ANY_TAG = /<\/?think\s*>/;
1360
- // Longest tail we may hold back: the tag name plus a few whitespace chars.
1361
- const THINK_CARRY_LIMIT = 16;
1362
-
1363
- function thinkStateOf(session) {
1364
- if (!session._thinkState) {
1365
- session._thinkState = { inThink: false, pendingStrip: false, carry: '', thinkBlock: 0, emittedBlock: 0 };
1366
- }
1367
- return session._thinkState;
1368
- }
1369
-
1370
- function resetThinkState(session) {
1371
- const state = thinkStateOf(session);
1372
- state.inThink = false;
1373
- state.pendingStrip = false;
1374
- state.carry = '';
1375
- state.thinkBlock = 0;
1376
- state.emittedBlock = 0;
1377
- }
1378
-
1379
- // Could `value` still grow into a complete tag once more deltas arrive?
1380
- function couldGrowIntoThinkTag(value) {
1381
- if (!value.startsWith('<')) return false;
1382
- if (THINK_OPEN_TEXT.startsWith(value) || THINK_CLOSE_TEXT.startsWith(value)) return true;
1383
- // Name complete, tag still waiting on '>' after whitespace: '<think '.
1384
- return /^(<\/?think)\s*$/.test(value);
1385
- }
1386
-
1387
- // Longest suffix of `text` that could still grow into a tag.
1388
- function thinkCarrySuffix(text) {
1389
- for (let length = Math.min(text.length, THINK_CARRY_LIMIT); length >= 1; length -= 1) {
1390
- const tail = text.slice(-length);
1391
- if (couldGrowIntoThinkTag(tail)) return tail;
1392
- }
1393
- return '';
1394
- }
1395
-
1396
- // Splits one text part into ordered {think, text} pieces while mutating the
1397
- // session state. `pendingStrip` removes the first whitespace run after a
1398
- // closing tag — models open their reply with a blank line, which would
1399
- // otherwise lead every answer with dead space.
1400
- function parseThinkPieces(text, state, options = {}) {
1401
- const holdPartialTag = options.holdPartialTag !== false;
1402
- const pieces = [];
1403
- let rest = state.carry + String(text);
1404
- state.carry = '';
1405
- const pushText = value => {
1406
- if (!value) return;
1407
- if (state.pendingStrip) {
1408
- const stripped = value.replace(/^\s+/, '');
1409
- if (!stripped) return; // whitespace-only run continues into the next part
1410
- state.pendingStrip = false;
1411
- pieces.push({ think: false, text: stripped });
1412
- return;
1413
- }
1414
- pieces.push({ think: false, text: value });
1415
- };
1416
- const emitRun = value => {
1417
- if (!value) return;
1418
- if (state.inThink) pieces.push({ think: true, text: value, block: state.thinkBlock });
1419
- else pushText(value);
1420
- };
1421
- while (rest) {
1422
- const pattern = state.inThink ? THINK_CLOSE_TAG : THINK_ANY_TAG;
1423
- const match = rest.match(pattern);
1424
- if (!match) {
1425
- const carry = holdPartialTag ? thinkCarrySuffix(rest) : '';
1426
- emitRun(rest.slice(0, rest.length - carry.length));
1427
- state.carry = carry;
1428
- return pieces;
1429
- }
1430
- emitRun(rest.slice(0, match.index));
1431
- if (state.inThink) {
1432
- state.inThink = false;
1433
- state.pendingStrip = true;
1434
- } else if (match[0][1] === '/') {
1435
- state.pendingStrip = true; // stray close without an open: swallow it
1436
- } else {
1437
- state.inThink = true;
1438
- state.thinkBlock += 1; // a fresh block gets its own thinking-start
1439
- }
1440
- rest = rest.slice(match.index + match[0].length);
1441
- }
1442
- return pieces;
1443
- }
1444
-
1445
- // Emits parsed pieces in order. Each think BLOCK emits exactly one
1446
- // thinking-start (piece granularity: two blocks inside one part both start);
1447
- // a block continued across parts does not restart.
1448
- function emitThinkTextPieces(pieces, part, session, helpers, out) {
1449
- const state = thinkStateOf(session);
1450
- let resultText = '';
1451
- for (const piece of pieces) {
1452
- if (!piece.text) continue;
1453
- if (piece.think) {
1454
- const id = part.messageID || `think-${session.turns}`;
1455
- if (state.emittedBlock !== piece.block) {
1456
- out.push({ type: 'claude/thinking-start', id });
1457
- state.emittedBlock = piece.block;
1458
- }
1459
- out.push({ type: 'claude/thinking-delta', id, text: piece.text });
1460
- continue;
1461
- }
1462
- const id = part.messageID || `text-${session.turns}`;
1463
- out.push({ type: 'claude/text-start', id });
1464
- out.push({ type: 'claude/text-delta', id, text: piece.text });
1465
- resultText += piece.text;
1466
- }
1467
- // The turn result is the visible reply only; pure-thinking parts leave the
1468
- // previous result untouched instead of overwriting it with ''.
1469
- if (resultText) helpers.setResult(resultText);
1470
- }
1471
-
1472
- // No more deltas will arrive for this step: whatever partial tag is still
1473
- // carried is literal text (or literal thinking), never a dropped character.
1474
- function flushThinkCarry(session) {
1475
- const state = thinkStateOf(session);
1476
- if (!state.carry) return [];
1477
- return parseThinkPieces('', state, { holdPartialTag: false });
1478
- }
1479
-
1480
- // ---- serve record persistence helpers (module level) ----------------------
1481
- const SERVE_PERSIST_SCHEMA = 'opencode-serve-session/v1';
1482
- const MAX_SERVE_PERSISTED_EVENTS = 5000;
1483
-
1484
- function safeFileStem(value) {
1485
- return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
1486
- }
1487
-
1488
- // Mirrors the CLI runner's resolution rules: explicit config always wins;
1489
- // otherwise persistence is suppressed under the node:test runner. Injected
1490
- // serve doubles (spawn/fetch) do NOT suppress persistence.
1491
- function resolveServePersistence(options) {
1492
- const configured = options.persistence && typeof options.persistence === 'object' ? options.persistence : {};
1493
- const explicitlyConfigured = options.persistence != null;
1494
- const ephemeralTestContext = Boolean(process.env.NODE_TEST_CONTEXT);
1495
- const enabled = options.persistence !== null
1496
- && configured.enabled !== false
1497
- && (explicitlyConfigured || !ephemeralTestContext);
1498
- const dir = path.resolve(configured.dir || path.join(os.homedir(), '.agent-terminal', 'cli-sessions'));
1499
- return { enabled, dir };
1500
- }
1501
-
1502
- function opencodeCommandExists(command) {
1503
- try {
1504
- if (process.platform === 'win32') {
1505
- const invocation = resolveCliInvocation(command, ['serve']);
1506
- return path.isAbsolute(invocation.command);
1507
- }
1508
- if (path.isAbsolute(command)) return fs.existsSync(command);
1509
- const dirs = (process.env.PATH || '').split(path.delimiter);
1510
- return dirs.some(dir => {
1511
- try { fs.accessSync(path.join(dir, command)); return true; } catch { return false; }
1512
- });
1513
- } catch {
1514
- return false;
1515
- }
1516
- }
1517
-
1518
- // Image materialization for the serve transport (file parts reference local
1519
- // files; same on-disk discipline as the CLI runner: files exist only for the
1520
- // duration of the turn).
1521
- function prepareImages(projectPath, attachments = []) {
1522
- const images = attachments.filter(item => item.mediaType && item.mediaType.startsWith('image/'));
1523
- if (!images.length) return { imagePaths: [], cleanup() {} };
1524
- const root = path.join(path.resolve(projectPath), '.agent-terminal-attachments');
1525
- fs.mkdirSync(root, { recursive: true });
1526
- // Refuse a workspace symlink/junction that would write attachments elsewhere.
1527
- if (fs.lstatSync(root).isSymbolicLink()) throw new Error('attachment directory must not be a symbolic link');
1528
- const turnDir = path.join(root, crypto.randomUUID());
1529
- fs.mkdirSync(turnDir);
1530
- let cleaned = false;
1531
- const cleanup = () => {
1532
- if (cleaned) return;
1533
- fs.rmSync(turnDir, { recursive: true, force: true });
1534
- try { fs.rmdirSync(root); } catch (error) {
1535
- if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
1536
- }
1537
- cleaned = true;
1538
- };
1539
- try {
1540
- const imagePaths = images.map((item, index) => {
1541
- const extension = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[item.mediaType];
1542
- const file = path.join(turnDir, `${index}${extension}`);
1543
- fs.writeFileSync(file, Buffer.from(item.data, 'base64'), { mode: 0o600 });
1544
- return {
1545
- path: file,
1546
- // opencode file parts take a URL; local files use the file:// scheme.
1547
- part: { type: 'file', mime: item.mediaType, filename: item.name || `image-${index}${extension}`, url: `file:///${file.replace(/\\/g, '/')}` },
1548
- };
1549
- });
1550
- return { imagePaths, cleanup };
1551
- } catch (error) { cleanup(); throw error; }
1552
- }
1553
-
1554
- module.exports = { createOpenCodeDriver, parseOpenCodeLine, permissionRulesetFor };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { writeJson } = require('../json-file');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const { createCliJsonRunner, resolveCliInvocation } = require('./cli-json-runner');
9
+ const { createOpenCodeServerBridge } = require('../bridges/opencode-server-bridge');
10
+ const { stampEvent, resequenceRestoredEvents } = require('../event-stream');
11
+ const {
12
+ appendTextAttachments, normalizeAttachments, resolveOpenAIModel, resolveOpenAIBaseUrl,
13
+ } = require('../../../contracts');
14
+
15
+ const PROVIDER_ID = 'agent-terminal';
16
+ const PROVIDER_KEY_ENV = 'AGENT_TERMINAL_PROVIDER_API_KEY';
17
+ const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
18
+
19
+ // OpenCode driver, two transports (mirrors the codex driver's shape):
20
+ // serve (preferred) — one persistent `opencode serve` child per provider
21
+ // spec speaking HTTP + SSE, giving true token streaming
22
+ // (`message.part.delta`), live tool progress, per-session abort and
23
+ // multi-turn sessions without respawning. Protocol verified live
24
+ // against opencode 1.18.5 (see bridges/opencode-server-bridge.js).
25
+ // cli-json (fallback) — the historical `opencode run --format json`
26
+ // one-shot turns, used automatically when `spawn` is injected (the test
27
+ // double contract), OPENCODE_TRANSPORT=cli is set, or the serve child
28
+ // cannot start / keeps crashing.
29
+ //
30
+ // Auth is agent-owned (opencode auth) or profile-injected; workbench
31
+ // permission modes map to per-session opencode permission rulesets on serve
32
+ // (verified live: a denied permission removes the tool from the model's
33
+ // toolset) and to OPENCODE_CONFIG_CONTENT permission objects on the CLI.
34
+
35
+ function createOpenCodeDriver(options = {}) {
36
+ // Test doubles inject `spawn` and script CLI subprocess behavior; honoring
37
+ // that injection with the CLI path keeps the legacy contract intact.
38
+ const forcedCli = options.bridge === false
39
+ || process.env.OPENCODE_TRANSPORT === 'cli'
40
+ || Boolean(options.spawn);
41
+ if (!forcedCli && (options.serveSpawn || options.serveFetch || opencodeCommandExists(options.command || 'opencode'))) {
42
+ return createServeOpenCodeDriver(options);
43
+ }
44
+ return createCliOpenCodeRunner(options);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // CLI transport (previous behavior, kept verbatim as the fallback)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function createCliOpenCodeRunner(options = {}) {
52
+ return createCliJsonRunner({
53
+ agentId: 'opencode',
54
+ command: options.command || 'opencode',
55
+ spawn: options.spawn,
56
+ promptViaStdin: true,
57
+ ...(options.persistence !== undefined ? { persistence: options.persistence } : {}),
58
+
59
+ modelFor(profile) {
60
+ const model = resolveOpenAIModel(profile);
61
+ if (!model) return null;
62
+ return /^[A-Za-z0-9._:/-]+$/.test(model) ? `${isInjected(profile) ? `${PROVIDER_ID}/` : ''}${model}` : null;
63
+ },
64
+
65
+ envFor(profile, session) {
66
+ const env = {};
67
+ if (isInjected(profile)) {
68
+ env[PROVIDER_KEY_ENV] = profile.apiKey;
69
+ env.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfigFor(profile, session));
70
+ return env;
71
+ }
72
+ env.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfigFor(profile, session));
73
+ return env;
74
+ },
75
+
76
+ spawnArgs({ projectPath, model, threadId, imagePaths, permissionMode }) {
77
+ const args = ['run', '--format', 'json'];
78
+ // The native exe ignores spawn cwd when driven over stdin on Windows and
79
+ // resolves its project root from the parent process instead; --dir pins
80
+ // the workspace explicitly for every platform.
81
+ args.push('--dir', projectPath);
82
+ if (model) args.push('-m', model);
83
+ if (threadId) args.push('--session', threadId);
84
+ if (permissionMode === 'plan') args.push('--agent', 'plan');
85
+ // No effort flag: opencode's --variant selects agent variants, not
86
+ // reasoning effort, so passing effort levels there would be a lie.
87
+ for (const file of imagePaths || []) args.push('-f', file);
88
+ return args;
89
+ },
90
+
91
+ parseLine: parseOpenCodeLine,
92
+ });
93
+ }
94
+
95
+ // Shared config body for the CLI transport: permission object (per turn,
96
+ // because every turn respawns with fresh env) plus the injected provider.
97
+ function openCodeConfigFor(profile, session) {
98
+ const unrestricted = session.permissionMode === 'bypassPermissions';
99
+ const editable = unrestricted || session.permissionMode === 'acceptEdits';
100
+ const config = { permission: {
101
+ '*': 'allow', edit: editable ? 'allow' : 'deny', bash: editable ? 'allow' : 'deny',
102
+ external_directory: unrestricted ? 'allow' : 'deny', question: 'deny',
103
+ } };
104
+ if (isInjected(profile)) {
105
+ const endpoint = resolveOpenAIBaseUrl(profile);
106
+ if (!endpoint) throw new Error('the selected provider needs an OpenAI base URL for OpenCode');
107
+ config.provider = injectedProviderConfig(profile, endpoint);
108
+ }
109
+ return config;
110
+ }
111
+
112
+ function injectedProviderConfig(profile, endpoint) {
113
+ const model = resolveOpenAIModel(profile);
114
+ const entry = profile.models && Array.isArray(profile.models.list)
115
+ ? profile.models.list.find(item => item.id === profile.models.default) : null;
116
+ return { [PROVIDER_ID]: {
117
+ npm: '@ai-sdk/openai-compatible', name: profile.name || 'Agent Terminal',
118
+ options: { baseURL: endpoint, apiKey: `{env:${PROVIDER_KEY_ENV}}` },
119
+ models: { [model]: { name: model, tool_call: true,
120
+ // A custom provider has no models.dev capability entry. Permit
121
+ // image transport; the selected endpoint validates model support.
122
+ attachment: true, modalities: { input: ['text', 'image'], output: ['text'] },
123
+ ...(entry && entry.contextWindow ? { limit: { context: entry.contextWindow, output: 8192 } } : {}),
124
+ } },
125
+ } };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // serve transport
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function createServeOpenCodeDriver(options = {}) {
133
+ const command = options.command || 'opencode';
134
+ const log = typeof options.log === 'function' ? options.log : () => {};
135
+ const sessions = new Map();
136
+ const opencodeIds = new Map(); // opencode session id (ses_...) -> driver sessionId
137
+ // The CLI fallback runner: `cliSpawn` lets tests script fallback turns
138
+ // without `spawn`, which would itself force the CLI transport.
139
+ const cliRunner = createCliOpenCodeRunner({ ...options, spawn: options.cliSpawn });
140
+ const persist = resolveServePersistence(options);
141
+ let serveDiskIndex = null;
142
+ let nextId = 1;
143
+ let mode = 'serve';
144
+ let bridge = null;
145
+ let bridgeSpecKey = null;
146
+ let bridgeFailures = 0;
147
+
148
+ function summary(session) {
149
+ return {
150
+ sessionId: session.sessionId,
151
+ projectSlug: session.projectSlug,
152
+ state: session.state,
153
+ model: session.model,
154
+ selectedModel: session.selectedModel,
155
+ aiProfileId: session.aiProfileId,
156
+ permissionMode: session.permissionMode,
157
+ turns: session.turns,
158
+ startedAt: session.startedAt,
159
+ // Live activity marker: turn end refreshes it (see turns += 1 site).
160
+ updatedAt: session.updatedAt || session.startedAt,
161
+ endedAt: session.endedAt,
162
+ pendingPermission: session.pendingPermission,
163
+ };
164
+ }
165
+
166
+ function emit(session, event) {
167
+ stampEvent(session, event);
168
+ session.outputBuffer.push(event);
169
+ if (session.outputBuffer.length > MAX_SERVE_PERSISTED_EVENTS) session.outputBuffer.shift();
170
+ for (const listener of session.listeners) {
171
+ try { listener(event); } catch { /* listener errors never break the turn */ }
172
+ }
173
+ }
174
+
175
+ function setState(session, state) {
176
+ session.state = state;
177
+ emit(session, { type: 'claude/state', state, turn: session.turns });
178
+ }
179
+
180
+ function emitDiag(session, event) {
181
+ const text = event.text != null ? event.text : event.message;
182
+ if (text === session.lastDiag) return;
183
+ session.lastDiag = String(text == null ? '' : text);
184
+ emit(session, event);
185
+ }
186
+
187
+ function addUsage(session, usage) {
188
+ if (!usage) return;
189
+ const input = Number(usage.inputTokens || usage.input || 0) || 0;
190
+ const output = Number(usage.outputTokens || usage.output || 0) || 0;
191
+ const cached = Number(usage.cachedTokens || (usage.cache && usage.cache.read) || 0) || 0;
192
+ session.usage.inputTokens += input;
193
+ session.usage.outputTokens += output;
194
+ session.usage.cachedTokens += cached;
195
+ if (session.turnUsage) {
196
+ session.turnUsage.inputTokens += input;
197
+ session.turnUsage.outputTokens += output;
198
+ session.turnUsage.cachedTokens += cached;
199
+ const turnTotal = session.turnUsage.inputTokens + session.turnUsage.outputTokens + session.turnUsage.cachedTokens;
200
+ session.peakTurnTokens = Math.max(session.peakTurnTokens || 0, turnTotal);
201
+ }
202
+ }
203
+
204
+ // ---- serve session persistence (shared cli-sessions directory) -----------
205
+ // Pure serve sessions never touch the CLI runner, so without this store a
206
+ // restart wiped them from the sidebar. Records live beside the CLI ones but
207
+ // carry their own schema; the opencode session id (ses_...) is stored as
208
+ // threadId so the next turn continues the server-side conversation.
209
+
210
+ function serveRecordFile(sessionId) {
211
+ return path.join(persist.dir, `${safeFileStem(sessionId)}.json`);
212
+ }
213
+
214
+ function readServeRecord(file) {
215
+ try {
216
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
217
+ if (parsed && parsed.schema === SERVE_PERSIST_SCHEMA && parsed.agentId === 'opencode'
218
+ && parsed.transport === 'serve' && typeof parsed.sessionId === 'string' && parsed.sessionId) return parsed;
219
+ } catch { /* corrupt or foreign records are skipped, never fatal */ }
220
+ return null;
221
+ }
222
+
223
+ function syncServeDiskIndex() {
224
+ if (!persist.enabled) return null;
225
+ if (!serveDiskIndex) serveDiskIndex = new Map();
226
+ else serveDiskIndex.clear();
227
+ let names = [];
228
+ try { names = fs.readdirSync(persist.dir).filter(name => name.endsWith('.json')); } catch { return serveDiskIndex; }
229
+ for (const name of names) {
230
+ const file = path.join(persist.dir, name);
231
+ const record = readServeRecord(file);
232
+ if (record) serveDiskIndex.set(record.sessionId, { file, summary: persistedServeSummary(record) });
233
+ }
234
+ return serveDiskIndex;
235
+ }
236
+
237
+ function persistedServeSummary(record) {
238
+ return {
239
+ sessionId: record.sessionId,
240
+ projectSlug: record.projectSlug,
241
+ state: 'idle', // a restart killed any live turn
242
+ model: record.model || null,
243
+ selectedModel: record.selectedModel || null,
244
+ aiProfileId: record.aiProfileId || null,
245
+ permissionMode: record.permissionMode || 'default',
246
+ turns: Number(record.turns) || 0,
247
+ startedAt: record.startedAt || '',
248
+ updatedAt: record.updatedAt || record.startedAt || '',
249
+ endedAt: record.endedAt || null,
250
+ pendingPermission: null,
251
+ };
252
+ }
253
+
254
+ function persistServeSession(session) {
255
+ if (!persist.enabled) return;
256
+ try {
257
+ const file = serveRecordFile(session.sessionId);
258
+ writeJson(file, {
259
+ schema: SERVE_PERSIST_SCHEMA,
260
+ agentId: 'opencode',
261
+ transport: 'serve',
262
+ sessionId: session.sessionId,
263
+ projectSlug: session.projectSlug,
264
+ projectPath: session.projectPath,
265
+ state: session.state,
266
+ model: session.model,
267
+ selectedModel: session.selectedModel,
268
+ aiProfileId: session.aiProfileId,
269
+ permissionMode: session.permissionMode,
270
+ effort: session.effort || null,
271
+ title: session.title || null,
272
+ turns: session.turns,
273
+ startedAt: session.startedAt,
274
+ endedAt: session.endedAt,
275
+ // The opencode session id (ses_...): prompting it again resumes the
276
+ // server-side conversation after a restart.
277
+ threadId: session.opencodeSessionId || null,
278
+ lastResult: session.lastResult || '',
279
+ usage: { ...session.usage },
280
+ peakTurnTokens: session.peakTurnTokens || 0,
281
+ eventSequence: Number(session.eventSequence) || 0,
282
+ // Old numeric cursors predate this session's numbering era; the
283
+ // boundary must survive restarts, not just the first restore.
284
+ legacySequenceReset: session.legacySequenceReset === true,
285
+ events: session.outputBuffer.slice(-MAX_SERVE_PERSISTED_EVENTS),
286
+ updatedAt: new Date().toISOString(),
287
+ });
288
+ if (serveDiskIndex) serveDiskIndex.set(session.sessionId, { file: serveRecordFile(session.sessionId), summary: summary(session) });
289
+ } catch {
290
+ // Read-only home or full disk degrades to memory-only behavior.
291
+ persist.enabled = false;
292
+ serveDiskIndex = null;
293
+ }
294
+ }
295
+
296
+ // Only ever removes OUR record: a CLI mirror may take over the session id.
297
+ function removeServeRecord(sessionId) {
298
+ if (serveDiskIndex) serveDiskIndex.delete(sessionId);
299
+ if (!persist.enabled) return;
300
+ try {
301
+ const file = serveRecordFile(sessionId);
302
+ if (fs.existsSync(file) && readServeRecord(file)) fs.rmSync(file, { force: true });
303
+ } catch { /* already gone */ }
304
+ }
305
+
306
+ function restoreServeSession(id) {
307
+ if (!persist.enabled) return null;
308
+ const index = syncServeDiskIndex();
309
+ const entry = index && index.get(id);
310
+ if (!entry) return null;
311
+ const record = readServeRecord(entry.file);
312
+ if (!record || record.sessionId !== id) { index.delete(id); return null; }
313
+ // Legacy records (written before stable event identity) carry unstamped
314
+ // events: renumber the retained tail explicitly and continue from there.
315
+ const restored = resequenceRestoredEvents(
316
+ Array.isArray(record.events) ? record.events.slice(-MAX_SERVE_PERSISTED_EVENTS) : [],
317
+ record.eventSequence,
318
+ );
319
+ const session = {
320
+ sessionId: record.sessionId,
321
+ projectSlug: record.projectSlug,
322
+ projectPath: record.projectPath,
323
+ state: 'idle',
324
+ model: record.model || null,
325
+ selectedModel: record.selectedModel || null,
326
+ aiProfileId: record.aiProfileId || null,
327
+ permissionMode: record.permissionMode || 'default',
328
+ effort: record.effort || null,
329
+ title: record.title || null,
330
+ turns: Number(record.turns) || 0,
331
+ startedAt: record.startedAt || new Date().toISOString(),
332
+ updatedAt: record.updatedAt || record.startedAt || new Date().toISOString(),
333
+ endedAt: record.endedAt || null,
334
+ pendingPermission: null,
335
+ outputBuffer: restored.events,
336
+ eventSequence: restored.eventSequence,
337
+ legacySequenceReset: restored.legacyRenumbered === true || record.legacySequenceReset === true,
338
+ listeners: new Set(),
339
+ opencodeSessionId: record.threadId || null,
340
+ currentTurn: null,
341
+ lastResult: record.lastResult || '',
342
+ lastDiag: '',
343
+ usage: {
344
+ inputTokens: Number(record.usage && record.usage.inputTokens) || 0,
345
+ outputTokens: Number(record.usage && record.usage.outputTokens) || 0,
346
+ cachedTokens: Number(record.usage && record.usage.cachedTokens) || 0,
347
+ },
348
+ peakTurnTokens: Number(record.peakTurnTokens) || 0,
349
+ turnUsage: null,
350
+ cliMirrorId: null,
351
+ pinnedCli: false,
352
+ turnPromise: Promise.resolve(),
353
+ };
354
+ sessions.set(id, session);
355
+ return session;
356
+ }
357
+
358
+ const runner = {
359
+ agentId: 'opencode',
360
+
361
+ startChatSession({ slug, projectPath, aiProfile, permissionMode, effort, title }) {
362
+ if (!slug) throw new Error('slug required');
363
+ if (!projectPath) throw new Error('projectPath required');
364
+ const sessionId = nextDriverSessionId();
365
+ const session = {
366
+ sessionId,
367
+ projectSlug: slug,
368
+ projectPath,
369
+ state: 'idle',
370
+ model: cliModelFor(aiProfile),
371
+ selectedModel: aiProfile && (aiProfile.mainModel || aiProfile.models && aiProfile.models.default) || '',
372
+ aiProfileId: aiProfile && aiProfile.id,
373
+ permissionMode: permissionMode || 'default',
374
+ effort: effort || null,
375
+ title: title || null,
376
+ turns: 0,
377
+ startedAt: new Date().toISOString(),
378
+ endedAt: null,
379
+ pendingPermission: null,
380
+ outputBuffer: [],
381
+ eventSequence: 0,
382
+ listeners: new Set(),
383
+ opencodeSessionId: null,
384
+ currentTurn: null,
385
+ lastResult: '',
386
+ lastDiag: '',
387
+ usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
388
+ peakTurnTokens: 0,
389
+ turnUsage: null,
390
+ cliMirrorId: null,
391
+ turnPromise: Promise.resolve(),
392
+ };
393
+ sessions.set(sessionId, session);
394
+ emit(session, {
395
+ type: 'claude/session-ready',
396
+ model: session.model,
397
+ aiProfileId: session.aiProfileId,
398
+ permissionMode: session.permissionMode,
399
+ message: 'opencode session is ready. Send a message to start the first turn.',
400
+ });
401
+ persistServeSession(session);
402
+ return { sessionId };
403
+ },
404
+
405
+ listSessions(filter = {}) {
406
+ const byId = new Map();
407
+ const boundMirrors = new Set();
408
+ for (const session of sessions.values()) {
409
+ if (filter.projectSlug && session.projectSlug !== filter.projectSlug) continue;
410
+ byId.set(session.sessionId, summary(session));
411
+ if (session.cliMirrorId) boundMirrors.add(session.cliMirrorId);
412
+ }
413
+ const extraSources = [cliRunner.listSessions(filter)];
414
+ const serveIndex = syncServeDiskIndex();
415
+ if (serveIndex) {
416
+ extraSources.push([...serveIndex.values()]
417
+ .map(entry => entry.summary)
418
+ .filter(item => !filter.projectSlug || item.projectSlug === filter.projectSlug));
419
+ }
420
+ for (const source of extraSources) {
421
+ for (const item of source) {
422
+ if (byId.has(item.sessionId) || boundMirrors.has(item.sessionId)) continue;
423
+ byId.set(item.sessionId, item);
424
+ }
425
+ }
426
+ return [...byId.values()];
427
+ },
428
+
429
+ getState(id) {
430
+ const session = sessions.get(id) || restoreServeSession(id);
431
+ if (!session) return cliRunner.getState(id);
432
+ return {
433
+ ...summary(session),
434
+ listenerCount: session.listeners.size,
435
+ bufferedEvents: session.outputBuffer.length,
436
+ };
437
+ },
438
+
439
+ getSession(id) { return sessions.get(id) || restoreServeSession(id) || cliRunner.getSession(id); },
440
+
441
+ getSessionTokenUsage(id) {
442
+ const session = sessions.get(id) || restoreServeSession(id);
443
+ if (!session) return cliRunner.getSessionTokenUsage(id);
444
+ if (session.cliMirrorId) return cliRunner.getSessionTokenUsage(session.cliMirrorId);
445
+ const { inputTokens, outputTokens } = session.usage;
446
+ const used = session.peakTurnTokens || 0;
447
+ return {
448
+ used,
449
+ inputTokens,
450
+ outputTokens,
451
+ hasUsage: Boolean(used || inputTokens || outputTokens),
452
+ };
453
+ },
454
+
455
+ subscribe(id, listener) {
456
+ const session = sessions.get(id) || restoreServeSession(id);
457
+ if (!session) return cliRunner.subscribe(id, listener);
458
+ for (const event of session.outputBuffer) listener(event);
459
+ session.listeners.add(listener);
460
+ return () => session.listeners.delete(listener);
461
+ },
462
+
463
+ async sendInput(id, text, profile, sendOptions = {}) {
464
+ const session = sessions.get(id) || restoreServeSession(id) || adoptDiskSession(id);
465
+ if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
466
+ if (ACTIVE_STATES.has(session.state)) {
467
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
468
+ }
469
+ const value = String(text || '').trim();
470
+ const attachments = normalizeAttachments(sendOptions.attachments || []);
471
+ if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
472
+ if (profile) {
473
+ session.model = cliModelFor(profile);
474
+ session.selectedModel = profile.mainModel || profile.models && profile.models.default || '';
475
+ }
476
+ if (sendOptions.permissionMode) session.permissionMode = sendOptions.permissionMode;
477
+ if (sendOptions.effort) session.effort = sendOptions.effort;
478
+ session.lastPromptText = value;
479
+
480
+ if (mode === 'cli' || session.pinnedCli) {
481
+ return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: true });
482
+ }
483
+
484
+ let prepared = null;
485
+ try {
486
+ await ensureBridge(profile);
487
+ prepared = prepareImages(session.projectPath, attachments);
488
+ await ensureOpenCodeSession(session);
489
+ } catch (error) {
490
+ if (prepared) prepared.cleanup();
491
+ if (shouldFallbackToCli(error)) {
492
+ switchToCli(error);
493
+ return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: true });
494
+ }
495
+ emit(session, { type: 'claude/error', message: `opencode serve unavailable: ${error.message}`.slice(0, 500) });
496
+ setState(session, 'idle');
497
+ return { started: false };
498
+ }
499
+
500
+ emit(session, {
501
+ type: 'claude/user-prompt',
502
+ text: value,
503
+ attachments: (sendOptions.displayAttachments || []).map(({ id, name, mediaType, size, width, height, url }) => ({ id, name, mediaType, size, width, height, url })),
504
+ });
505
+ setState(session, 'running');
506
+ // Until the first serve event lands the UI would show dead air (reasoning
507
+ // models think for tens of seconds first) — announce it.
508
+ setState(session, 'thinking');
509
+ // The turn context must exist BEFORE the prompt POST resolves: the SSE
510
+ // stream may already deliver the first events while prompt_async's 204
511
+ // is still in flight.
512
+ session.lastResult = '';
513
+ session.currentTurn = {
514
+ assistantMessageID: null,
515
+ assistantMessageIDs: new Set(),
516
+ userMessageIDs: new Set(), // echoed prompts never stream as output
517
+ parts: new Map(), // partID -> tracking state
518
+ sawEvent: false,
519
+ usageSeen: false,
520
+ turnFailed: false,
521
+ startedAt: Date.now(),
522
+ };
523
+ session.turnUsage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
524
+ session.turnPromise = new Promise(resolve => { session.currentTurn.resolve = resolve; });
525
+ try {
526
+ const parts = [
527
+ { type: 'text', text: appendTextAttachments(value, attachments) + (sendOptions.parentContext || '') + (sendOptions.knowledgeContext || '') },
528
+ ...prepared.imagePaths.map(item => item.part),
529
+ ];
530
+ await promptAsync(session, parts, profile);
531
+ // Images live only for the turn; the serve API reads them eagerly.
532
+ {
533
+ const turn = session.currentTurn;
534
+ const originalResolve = turn.resolve;
535
+ turn.resolve = () => { prepared.cleanup(); originalResolve(); };
536
+ }
537
+ return { started: true };
538
+ } catch (error) {
539
+ prepared.cleanup();
540
+ const turn = session.currentTurn;
541
+ session.currentTurn = null;
542
+ session.turnUsage = null;
543
+ if (turn && turn.resolve) turn.resolve();
544
+ if (shouldFallbackToCli(error)) {
545
+ switchToCli(error);
546
+ return runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt: false });
547
+ }
548
+ emit(session, { type: 'claude/error', message: `opencode turn failed to start: ${error.message}`.slice(0, 500) });
549
+ setState(session, 'idle');
550
+ return { started: false };
551
+ }
552
+ },
553
+
554
+ resolvePermission(id, requestId, decision) {
555
+ const session = sessions.get(id) || restoreServeSession(id);
556
+ // Neither transport maps opencode permission.asked events; the shared
557
+ // CLI runner answers with an honest 501.
558
+ return cliRunner.resolvePermission(
559
+ session && session.cliMirrorId ? session.cliMirrorId : id, requestId, decision);
560
+ },
561
+
562
+ updateSelection(id, profile, selectedModel) {
563
+ const session = sessions.get(id) || restoreServeSession(id);
564
+ if (!session) {
565
+ if (cliRunner.getState(id)) return cliRunner.updateSelection(id, profile, selectedModel);
566
+ throw Object.assign(new Error('session not found'), { status: 404 });
567
+ }
568
+ if (ACTIVE_STATES.has(session.state)) {
569
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
570
+ }
571
+ const fromAiProfileId = session.aiProfileId || null;
572
+ const fromModel = session.selectedModel || session.model || null;
573
+ session.aiProfileId = profile && profile.id || null;
574
+ session.selectedModel = selectedModel || (profile && (profile.mainModel || profile.models && profile.models.default)) || null;
575
+ session.model = cliModelFor(profile);
576
+ if (!session.cliMirrorId) {
577
+ emit(session, {
578
+ type: 'claude/selection-changed',
579
+ fromAiProfileId,
580
+ toAiProfileId: session.aiProfileId,
581
+ fromModel,
582
+ toModel: session.selectedModel,
583
+ });
584
+ }
585
+ if (session.cliMirrorId) {
586
+ try { cliRunner.updateSelection(session.cliMirrorId, profile, selectedModel); } catch { /* mirror may be mid-turn */ }
587
+ } else {
588
+ persistServeSession(session);
589
+ }
590
+ return summary(session);
591
+ },
592
+
593
+ abort(id, reason) {
594
+ const session = sessions.get(id) || restoreServeSession(id);
595
+ if (!session) return cliRunner.abort(id, reason);
596
+ session.abortedByUser = true;
597
+ if (mode === 'serve' && session.currentTurn && session.opencodeSessionId && bridge && bridge.state === 'ready') {
598
+ // Preferred path: per-session abort on the shared serve instance
599
+ // (verified: POST /session/{id}/abort interrupts only that session).
600
+ bridge.request('POST', `/session/${session.opencodeSessionId}/abort`, { timeoutMs: 5000 })
601
+ .catch(() => log(`opencode abort endpoint failed for ${session.opencodeSessionId}`));
602
+ }
603
+ if (session.currentTurn) {
604
+ session.state = 'aborted';
605
+ emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
606
+ finishServeTurn(session, {});
607
+ }
608
+ if (session.cliMirrorId) cliRunner.abort(session.cliMirrorId, reason);
609
+ if (!['idle', 'ended'].includes(session.state)) {
610
+ session.state = 'aborted';
611
+ emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
612
+ setState(session, 'idle');
613
+ }
614
+ if (!session.cliMirrorId) persistServeSession(session);
615
+ },
616
+
617
+ deleteSession(id) {
618
+ const session = sessions.get(id) || restoreServeSession(id);
619
+ if (!session) {
620
+ const index = syncServeDiskIndex();
621
+ if (index && index.has(id)) { removeServeRecord(id); return true; }
622
+ return cliRunner.deleteSession(id);
623
+ }
624
+ if (ACTIVE_STATES.has(session.state)) runner.abort(id);
625
+ if (session.opencodeSessionId) {
626
+ opencodeIds.delete(session.opencodeSessionId);
627
+ if (mode === 'serve' && bridge && bridge.state === 'ready') {
628
+ bridge.request('DELETE', `/session/${session.opencodeSessionId}`).catch(() => {});
629
+ }
630
+ }
631
+ const deleted = sessions.delete(id);
632
+ if (session.cliMirrorId) cliRunner.deleteSession(session.cliMirrorId);
633
+ removeServeRecord(id);
634
+ return deleted;
635
+ },
636
+
637
+ async listSupportedCommands() { return []; },
638
+
639
+ findClaudeExecutableForSdk() { return null; },
640
+
641
+ // Test seams.
642
+ get transportMode() { return mode; },
643
+ get bridgeState() { return bridge ? bridge.state : 'uncreated'; },
644
+ };
645
+
646
+ // -- bridge lifecycle ----------------------------------------------------
647
+
648
+ function bridgeSpecFor(profile) {
649
+ if (!isInjected(profile)) return { key: 'agent-owned', env: {} };
650
+ const endpoint = resolveOpenAIBaseUrl(profile); // throws without a base URL
651
+ const model = resolveOpenAIModel(profile);
652
+ return {
653
+ key: `injected:${endpoint}`,
654
+ env: {
655
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({ provider: injectedProviderConfig(profile, endpoint) }),
656
+ [PROVIDER_KEY_ENV]: profile.apiKey,
657
+ },
658
+ };
659
+ }
660
+
661
+ async function ensureBridge(profile) {
662
+ const spec = bridgeSpecFor(profile); // invalid profiles throw here (no fallback)
663
+ if (bridge && bridge.state === 'closed') { bridge = null; bridgeSpecKey = null; }
664
+ if (bridge && bridge.state === 'crashed') recycleBridge('bridge exhausted its restart budget');
665
+ if (bridge && bridgeSpecKey !== spec.key) recycleBridge('provider spec changed');
666
+ if (!bridge) {
667
+ bridge = createOpenCodeServerBridge({
668
+ command,
669
+ env: spec.env,
670
+ spawn: options.serveSpawn,
671
+ fetch: options.serveFetch,
672
+ ...(options.allocatePort !== undefined ? { allocatePort: options.allocatePort } : {}),
673
+ onEvent: handleServeEvent,
674
+ onSseGap: reconcileSseGap,
675
+ onUnexpectedExit: handleBridgeCrash,
676
+ onReady: () => { bridgeFailures = 0; },
677
+ healthTimeoutMs: options.healthTimeoutMs,
678
+ idleTimeoutMs: options.idleTimeoutMs !== undefined ? options.idleTimeoutMs : 10 * 60 * 1000,
679
+ requestTimeoutMs: options.requestTimeoutMs,
680
+ restartBackoffMs: options.restartBackoffMs,
681
+ sseReconnectMs: options.sseReconnectMs,
682
+ heartbeatTimeoutMs: options.heartbeatTimeoutMs,
683
+ log,
684
+ });
685
+ bridgeSpecKey = spec.key;
686
+ }
687
+ await bridge.start();
688
+ return bridge;
689
+ }
690
+
691
+ // The bridge is going away (crash, recycle, transport switch): every live
692
+ // turn finishes with an error because its end signal will never arrive.
693
+ // (finishServeTurn owns nulling currentTurn and resolving the turn promise.)
694
+ function failoverSessions(reason) {
695
+ for (const session of sessions.values()) {
696
+ if (!session.currentTurn) continue;
697
+ finishServeTurn(session, { error: String(reason).slice(0, 500) });
698
+ }
699
+ }
700
+
701
+ function recycleBridge(reason) {
702
+ log(`recycling opencode serve bridge: ${reason}`);
703
+ if (bridge) bridge.close().catch(() => {});
704
+ bridge = null;
705
+ bridgeSpecKey = null;
706
+ failoverSessions(`opencode serve exited: ${reason}`);
707
+ }
708
+
709
+ function handleBridgeCrash(error) {
710
+ if (mode !== 'serve') return;
711
+ failoverSessions(`opencode serve exited unexpectedly: ${error && error.message || 'crashed'}`);
712
+ bridgeFailures += 1;
713
+ if (bridgeFailures >= 2) switchToCli(error);
714
+ }
715
+
716
+ function switchToCli(error) {
717
+ if (mode === 'cli') return;
718
+ mode = 'cli';
719
+ recycleBridge(`falling back to opencode CLI: ${error && error.message}`);
720
+ for (const session of sessions.values()) {
721
+ emitDiag(session, {
722
+ type: 'claude/stderr',
723
+ text: `opencode serve unavailable (${String(error && error.message || error).slice(0, 200)}); using opencode run fallback`,
724
+ });
725
+ }
726
+ }
727
+
728
+ function shouldFallbackToCli(error) {
729
+ if (!error) return false;
730
+ if (error.code === 'BRIDGE_SPAWN_FAILED') return true; // ENOENT / early exit
731
+ if (error.code === 'BRIDGE_HEALTH_TIMEOUT') return true; // handshake failed
732
+ if (error.code === 'BRIDGE_CRASHED' || error.code === 'BRIDGE_CLOSED') return true;
733
+ if (error.code === 'BRIDGE_REQUEST_FAILED') return true; // connection refused: child gone
734
+ return false;
735
+ }
736
+
737
+ // -- serve session lifecycle ----------------------------------------------
738
+
739
+ // A model ref for prompt_async: injected profiles address the custom
740
+ // provider; agent-owned models use the 'provider/model' convention when the
741
+ // profile carries one, otherwise the server default applies.
742
+ function modelRefFor(session, profile) {
743
+ if (isInjected(profile)) {
744
+ const model = resolveOpenAIModel(profile);
745
+ return model ? { providerID: PROVIDER_ID, modelID: model } : null;
746
+ }
747
+ const model = session.model;
748
+ if (!model || !model.includes('/')) return null;
749
+ const slash = model.indexOf('/');
750
+ const providerID = model.slice(0, slash);
751
+ const modelID = model.slice(slash + 1);
752
+ return providerID && modelID ? { providerID, modelID } : null;
753
+ }
754
+
755
+ async function ensureOpenCodeSession(session) {
756
+ if (session.opencodeSessionId) {
757
+ // Restored records carry their id but not the routing entry (the map
758
+ // died with the previous process); rebind before the turn starts.
759
+ opencodeIds.set(session.opencodeSessionId, session.sessionId);
760
+ return;
761
+ }
762
+ const created = await bridge.request('POST', `/session?directory=${encodeURIComponent(session.projectPath)}`, {
763
+ body: { permission: permissionRulesetFor(session.permissionMode) },
764
+ timeoutMs: 30000,
765
+ });
766
+ const id = created && created.id;
767
+ if (!id) throw new Error('opencode serve did not return a session id');
768
+ session.opencodeSessionId = id;
769
+ opencodeIds.set(id, session.sessionId);
770
+ if (created.title && !session.title) session.title = String(created.title).slice(0, 200);
771
+ persistServeSession(session);
772
+ }
773
+
774
+ async function promptAsync(session, parts, profile) {
775
+ const body = {
776
+ parts,
777
+ ...(modelRefFor(session, profile) ? { model: modelRefFor(session, profile) } : {}),
778
+ ...(session.permissionMode === 'plan' ? { agent: 'plan' } : {}),
779
+ };
780
+ try {
781
+ await bridge.request('POST', `/session/${session.opencodeSessionId}/prompt_async`, {
782
+ body, timeoutMs: 30000,
783
+ });
784
+ } catch (error) {
785
+ if (error && error.code === 'BRIDGE_HTTP_ERROR' && error.httpStatus === 404) {
786
+ // The opencode session was rolled away (server db prune); continue in
787
+ // a fresh one rather than failing the turn.
788
+ log(`opencode session ${session.opencodeSessionId} vanished; creating a new one`);
789
+ opencodeIds.delete(session.opencodeSessionId);
790
+ session.opencodeSessionId = null;
791
+ await ensureOpenCodeSession(session);
792
+ await bridge.request('POST', `/session/${session.opencodeSessionId}/prompt_async`, {
793
+ body, timeoutMs: 30000,
794
+ });
795
+ return;
796
+ }
797
+ throw error;
798
+ }
799
+ }
800
+
801
+ // -- serve event mapping ---------------------------------------------------
802
+
803
+ function routeServeSession(opencodeSessionId) {
804
+ if (!opencodeSessionId) return null;
805
+ const driverId = opencodeIds.get(opencodeSessionId);
806
+ return driverId ? sessions.get(driverId) : null;
807
+ }
808
+
809
+ function markActivity(session) {
810
+ const turn = session.currentTurn;
811
+ if (!turn) return;
812
+ if (!turn.sawEvent) {
813
+ turn.sawEvent = true;
814
+ if (session.state === 'thinking') setState(session, 'running');
815
+ }
816
+ }
817
+
818
+ function partTracker(session, partID) {
819
+ const turn = session.currentTurn;
820
+ if (!turn || !partID) return null;
821
+ if (!turn.parts.has(partID)) {
822
+ turn.parts.set(partID, { type: null, rawLen: 0, emittedText: '', announced: false, toolDone: false, counted: false });
823
+ }
824
+ return turn.parts.get(partID);
825
+ }
826
+
827
+ function handleServeEvent(payload) {
828
+ const type = payload && payload.type;
829
+ const properties = (payload && payload.properties) || {};
830
+ const session = routeServeSession(properties.sessionID);
831
+ if (!session) return; // events for unknown sessions are ignored
832
+ switch (type) {
833
+ case 'session.created':
834
+ case 'session.updated': return onSessionUpdated(session, properties);
835
+ case 'session.status': return onSessionStatus(session, properties);
836
+ case 'session.idle': return onSessionIdle(session);
837
+ case 'session.error': return onSessionError(session, properties);
838
+ case 'message.updated': return onMessageUpdated(session, properties);
839
+ case 'message.part.updated': return onMessagePartUpdated(session, properties);
840
+ case 'message.part.delta': return onMessagePartDelta(session, properties);
841
+ default: return; // sync, message.removed, session.diff, permission.asked, ...
842
+ }
843
+ }
844
+
845
+ function onSessionUpdated(session, { info }) {
846
+ if (info && info.title && !session.title) {
847
+ session.title = String(info.title).slice(0, 200);
848
+ if (!session.cliMirrorId) persistServeSession(session);
849
+ }
850
+ }
851
+
852
+ function onSessionStatus(session, { status }) {
853
+ if (status && status.type === 'busy') markActivity(session);
854
+ }
855
+
856
+ function onSessionError(session, { error }) {
857
+ markActivity(session);
858
+ const message = String((error && (error.message || error.name)) || error || 'opencode session error').slice(0, 500);
859
+ emitDiag(session, { type: 'claude/error', message });
860
+ if (session.currentTurn) session.currentTurn.turnFailed = true;
861
+ }
862
+
863
+ function onMessageUpdated(session, { info }) {
864
+ if (!info || !session.currentTurn) return;
865
+ if (info.role === 'user') {
866
+ // Echoed user messages (this turn's prompt and trailing re-updates of
867
+ // previous ones) must never stream as assistant output.
868
+ if (info.id) session.currentTurn.userMessageIDs.add(info.id);
869
+ return;
870
+ }
871
+ if (info.role !== 'assistant') return;
872
+ const turn = session.currentTurn;
873
+ if (info.id && !turn.assistantMessageIDs.has(info.id) && info.time?.created && info.time.created < turn.startedAt - 5000) return;
874
+ markActivity(session);
875
+ // Tool rounds and the final answer have distinct assistant message IDs.
876
+ if (info.id) { turn.assistantMessageIDs.add(info.id); turn.assistantMessageID = info.id; }
877
+ // Completed assistant messages carry the final token totals; they back
878
+ // up the per-step-finish usage when a turn had no step-finish parts.
879
+ if (info.time && info.time.completed && info.tokens && info.tokens.total != null) {
880
+ session.currentTurn.messageTokens = info.tokens;
881
+ }
882
+ }
883
+
884
+ // Parts of echoed user prompts (or stale messages) must not leak into the
885
+ // assistant stream; the turn tracks every user message id it has seen and
886
+ // the assistant message this turn belongs to.
887
+ function partBelongsToTurn(turn, messageID) {
888
+ if (messageID && turn.userMessageIDs.has(messageID)) return false;
889
+ if (messageID && turn.assistantMessageIDs.has(messageID)) return true;
890
+ if (turn.assistantMessageID && messageID && messageID !== turn.assistantMessageID) return false;
891
+ return true;
892
+ }
893
+
894
+ function onMessagePartUpdated(session, { part }) {
895
+ if (!part || !session.currentTurn) return;
896
+ if (!partBelongsToTurn(session.currentTurn, part.messageID)) return;
897
+ if (!session.currentTurn.assistantMessageID && part.messageID) {
898
+ session.currentTurn.assistantMessageID = part.messageID;
899
+ session.currentTurn.assistantMessageIDs.add(part.messageID);
900
+ }
901
+ markActivity(session);
902
+ if (part.type === 'step-start') {
903
+ partTracker(session, part.id); // mark seen: reconcile must not replay it
904
+ // Step boundaries flush any carried partial think tag as literal text
905
+ // and reset the parse context — identical to the CLI transport.
906
+ for (const piece of flushThinkCarry(session)) emitServeThinkPiece(session, piece, part);
907
+ resetThinkState(session);
908
+ return;
909
+ }
910
+ if (part.type === 'step-finish') {
911
+ const tracker = partTracker(session, part.id);
912
+ if (tracker && tracker.counted) return; // reconciled replay: usage counted once
913
+ if (tracker) tracker.counted = true;
914
+ const tokens = part.tokens || {};
915
+ addUsage(session, { inputTokens: tokens.input, outputTokens: tokens.output, cachedTokens: tokens.cache && tokens.cache.read });
916
+ session.currentTurn.usageSeen = true;
917
+ emit(session, {
918
+ type: 'claude/usage',
919
+ usage: { input_tokens: Number(tokens.input || 0), output_tokens: Number(tokens.output || 0) },
920
+ });
921
+ return;
922
+ }
923
+ if (part.type === 'text') {
924
+ const tracker = partTracker(session, part.id);
925
+ if (!tracker) return;
926
+ tracker.type = 'text';
927
+ emitServeTextSuffix(session, tracker, part, String(part.text || ''));
928
+ return;
929
+ }
930
+ if (part.type === 'reasoning') {
931
+ const tracker = partTracker(session, part.id);
932
+ if (!tracker) return;
933
+ tracker.type = 'reasoning';
934
+ const text = String(part.text || '');
935
+ if (text.length > tracker.emittedText.length) {
936
+ const suffix = text.slice(tracker.emittedText.length);
937
+ tracker.emittedText = text;
938
+ if (!tracker.announced) {
939
+ tracker.announced = true;
940
+ emit(session, { type: 'claude/thinking-start', id: part.id || part.messageID });
941
+ }
942
+ emit(session, { type: 'claude/thinking-delta', id: part.id || part.messageID, text: suffix });
943
+ }
944
+ return;
945
+ }
946
+ if (part.type === 'tool') return onToolPart(session, part);
947
+ }
948
+
949
+ // Tool parts arrive as state snapshots pending -> running -> completed; the
950
+ // first snapshot with input announces the tool-use, the terminal status
951
+ // emits the result. Live output grows on the running snapshots.
952
+ function onToolPart(session, part) {
953
+ const tracker = partTracker(session, part.id || part.callID);
954
+ if (!tracker) return;
955
+ const state = part.state || {};
956
+ const id = part.callID || part.id;
957
+ if (!tracker.announced && (state.input && Object.keys(state.input).length || state.status === 'running' || state.status === 'completed')) {
958
+ tracker.announced = true;
959
+ setState(session, 'tool-running');
960
+ emit(session, {
961
+ type: 'claude/tool-use',
962
+ id,
963
+ name: part.tool || 'tool',
964
+ input: state.input || {},
965
+ });
966
+ }
967
+ if (!tracker.toolDone && (state.status === 'completed' || state.status === 'error')) {
968
+ tracker.toolDone = true;
969
+ setState(session, 'running');
970
+ const output = state.status === 'error'
971
+ ? String(state.error || state.output || 'tool failed')
972
+ : String(state.output != null ? state.output : '');
973
+ emit(session, {
974
+ type: 'claude/tool-result',
975
+ id,
976
+ output: output.slice(0, 4000),
977
+ isError: state.status === 'error',
978
+ ...(state.metadata && state.metadata.diff !== undefined ? { diff: state.metadata.diff } : {}),
979
+ });
980
+ }
981
+ }
982
+
983
+ // True streaming deltas (field 'text' on text parts). The delta runs
984
+ // through the same <think> stripping state machine as the CLI transport.
985
+ function onMessagePartDelta(session, { partID, messageID, field, delta }) {
986
+ const turn = session.currentTurn;
987
+ if (!turn || field !== 'text' || !partID) return;
988
+ if (!partBelongsToTurn(turn, messageID)) return;
989
+ if (!turn.assistantMessageID && messageID) { turn.assistantMessageID = messageID; turn.assistantMessageIDs.add(messageID); }
990
+ markActivity(session);
991
+ const tracker = partTracker(session, partID);
992
+ if (!tracker) return;
993
+ if (tracker.type === null) tracker.type = 'text'; // deltas precede the snapshot sometimes
994
+ if (tracker.type === 'reasoning') {
995
+ if (!tracker.announced) {
996
+ tracker.announced = true;
997
+ emit(session, { type: 'claude/thinking-start', id: partID });
998
+ }
999
+ tracker.emittedText += String(delta || '');
1000
+ emit(session, { type: 'claude/thinking-delta', id: partID, text: String(delta || '') });
1001
+ return;
1002
+ }
1003
+ emitServeTextDelta(session, tracker, partID, String(delta || ''));
1004
+ }
1005
+
1006
+ // Feeds one text delta through the think-strip state machine and emits the
1007
+ // ordered thinking/text events (mirror of the CLI parseOpenCodeLine path).
1008
+ // `rawLen` counts the RAW characters consumed (deltas and snapshots speak
1009
+ // the same raw text; only the emitted pieces are stripped).
1010
+ function emitServeTextDelta(session, tracker, partID, delta) {
1011
+ tracker.rawLen += delta.length;
1012
+ const pieces = parseThinkPieces(delta, thinkStateOf(session));
1013
+ for (const piece of pieces) {
1014
+ if (!piece.text) continue;
1015
+ if (piece.think) {
1016
+ if (thinkStateOf(session).emittedBlock !== piece.block) {
1017
+ emit(session, { type: 'claude/thinking-start', id: partID });
1018
+ thinkStateOf(session).emittedBlock = piece.block;
1019
+ }
1020
+ emit(session, { type: 'claude/thinking-delta', id: partID, text: piece.text });
1021
+ continue;
1022
+ }
1023
+ if (!tracker.announced) {
1024
+ tracker.announced = true;
1025
+ emit(session, { type: 'claude/text-start', id: partID });
1026
+ }
1027
+ emit(session, { type: 'claude/text-delta', id: partID, text: piece.text });
1028
+ session.lastResult += piece.text;
1029
+ }
1030
+ }
1031
+
1032
+ function emitServeThinkPiece(session, piece, part) {
1033
+ if (!piece || !piece.text) return;
1034
+ if (piece.think) {
1035
+ emit(session, { type: 'claude/thinking-start', id: part.messageID || 'think' });
1036
+ emit(session, { type: 'claude/thinking-delta', id: part.messageID || 'think', text: piece.text });
1037
+ return;
1038
+ }
1039
+ emit(session, { type: 'claude/text-start', id: part.messageID || 'text' });
1040
+ emit(session, { type: 'claude/text-delta', id: part.messageID || 'text', text: piece.text });
1041
+ session.lastResult += piece.text;
1042
+ }
1043
+
1044
+ // Snapshot recovery: emits the not-yet-emitted suffix of a text part. With
1045
+ // healthy deltas this is a no-op; after an SSE gap (or on servers that only
1046
+ // send snapshots) it fills the hole with the accumulated text.
1047
+ function emitServeTextSuffix(session, tracker, part, snapshotText) {
1048
+ if (snapshotText.length <= tracker.rawLen) return;
1049
+ const suffix = snapshotText.slice(tracker.rawLen);
1050
+ emitServeTextDelta(session, tracker, part.id, suffix);
1051
+ }
1052
+
1053
+ function onSessionIdle(session) {
1054
+ const turn = session.currentTurn;
1055
+ if (!turn) return;
1056
+ const failed = turn.turnFailed;
1057
+ if (!turn.usageSeen && turn.messageTokens) {
1058
+ // Step-finish usage never arrived; the assistant message carries it.
1059
+ const tokens = turn.messageTokens;
1060
+ addUsage(session, { inputTokens: tokens.input, outputTokens: tokens.output, cachedTokens: tokens.cache && tokens.cache.read });
1061
+ turn.usageSeen = true;
1062
+ emit(session, {
1063
+ type: 'claude/usage',
1064
+ usage: { input_tokens: Number(tokens.input || 0), output_tokens: Number(tokens.output || 0) },
1065
+ });
1066
+ }
1067
+ finishServeTurn(session, { error: failed ? 'opencode turn failed' : null });
1068
+ }
1069
+
1070
+ function finishServeTurn(session, { error = null } = {}) {
1071
+ const turn = session.currentTurn;
1072
+ session.currentTurn = null;
1073
+ session.turns += 1;
1074
+ session.updatedAt = new Date().toISOString();
1075
+ if (error) {
1076
+ emitDiag(session, { type: 'claude/error', message: String(error).slice(0, 500) });
1077
+ }
1078
+ emit(session, {
1079
+ type: 'claude/result',
1080
+ result: error ? '' : session.lastResult,
1081
+ model: session.model,
1082
+ isError: Boolean(error),
1083
+ });
1084
+ session.abortedByUser = false;
1085
+ session.turnUsage = null;
1086
+ if (!session.endedAt) session.state = 'idle';
1087
+ emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
1088
+ if (!session.cliMirrorId) persistServeSession(session);
1089
+ // A completed turn proves the bridge is healthy again.
1090
+ if (!error) bridgeFailures = 0;
1091
+ if (turn && turn.resolve) turn.resolve();
1092
+ }
1093
+
1094
+ // The SSE stream dropped mid-flight: missed deltas are backfilled by the
1095
+ // snapshot-diff on the next part updates; a turn whose END fell into the
1096
+ // gap is detected by refetching the message list (a completed assistant
1097
+ // message newer than the turn start finishes the turn).
1098
+ function reconcileSseGap() {
1099
+ if (mode !== 'serve' || !bridge || bridge.state !== 'ready') return;
1100
+ for (const session of sessions.values()) {
1101
+ const turn = session.currentTurn;
1102
+ if (!turn || !session.opencodeSessionId) continue;
1103
+ bridge.request('GET', `/session/${session.opencodeSessionId}/message`, { timeoutMs: 15000 })
1104
+ .then(messages => {
1105
+ if (!Array.isArray(messages) || !session.currentTurn || session.currentTurn !== turn) return;
1106
+ let completed = null;
1107
+ for (const message of messages) {
1108
+ const info = message && message.info;
1109
+ if (!info || info.role !== 'assistant' || !info.time || !info.time.completed) continue;
1110
+ if (info.time.completed < turn.startedAt - 5000) continue;
1111
+ if (!completed || info.time.completed > completed.info.time.completed) completed = message;
1112
+ }
1113
+ if (!completed) return;
1114
+ // Re-feed the parts through the snapshot handlers. Text and tool
1115
+ // parts are idempotent through the emitted-length/status trackers;
1116
+ // step boundaries already seen are skipped so their usage is never
1117
+ // counted twice and the think state is not reset mid-block.
1118
+ for (const part of completed.parts || []) {
1119
+ if (!part || !part.id) continue;
1120
+ const known = turn.parts.has(part.id);
1121
+ if (known && (part.type === 'step-start' || part.type === 'step-finish')) continue;
1122
+ onMessagePartUpdated(session, { part });
1123
+ }
1124
+ if (session.currentTurn === turn) onSessionIdle(session);
1125
+ })
1126
+ .catch(error => log(`opencode gap reconciliation failed: ${error.message}`));
1127
+ }
1128
+ }
1129
+
1130
+ // -- CLI fallback plumbing ------------------------------------------------
1131
+
1132
+ // The CLI fallback runs inside a mirror session of the internal CLI runner;
1133
+ // its events are re-emitted on the owning serve session (see codex-driver).
1134
+ function attachMirror(session) {
1135
+ if (session.mirrorAttached) return;
1136
+ session.mirrorAttached = true;
1137
+ cliRunner.subscribe(session.cliMirrorId, event => {
1138
+ if (event.type === 'claude/session-ready'
1139
+ && session.outputBuffer.some(item => item.type === 'claude/session-ready')) return;
1140
+ if (event.type === 'claude/user-prompt' && session.skipNextMirrorPrompt) {
1141
+ session.skipNextMirrorPrompt = false;
1142
+ return;
1143
+ }
1144
+ if (event.type === 'claude/state') {
1145
+ session.state = event.state;
1146
+ if (typeof event.turn === 'number') session.turns = event.turn;
1147
+ }
1148
+ emit(session, event);
1149
+ });
1150
+ }
1151
+
1152
+ async function runCliTurn(session, value, profile, sendOptions, attachments, { emitPrompt }) {
1153
+ attachments = attachments || [];
1154
+ if (!session.cliMirrorId) {
1155
+ removeServeRecord(session.sessionId);
1156
+ const { sessionId: mirrorId } = cliRunner.startChatSession({
1157
+ slug: session.projectSlug,
1158
+ projectPath: session.projectPath,
1159
+ aiProfile: { id: session.aiProfileId, models: { default: session.selectedModel || '' } },
1160
+ permissionMode: session.permissionMode,
1161
+ effort: session.effort,
1162
+ title: session.title,
1163
+ });
1164
+ session.cliMirrorId = mirrorId;
1165
+ // An opencode session id (from live turns or a restored record) is the
1166
+ // same id `opencode run --session` resumes, so the mirror inherits it.
1167
+ if (session.opencodeSessionId) {
1168
+ const mirror = cliRunner.getSession(mirrorId);
1169
+ if (mirror && !mirror.threadId) mirror.threadId = session.opencodeSessionId;
1170
+ }
1171
+ session.skipNextMirrorPrompt = !emitPrompt;
1172
+ attachMirror(session);
1173
+ } else if (!emitPrompt) {
1174
+ session.skipNextMirrorPrompt = true;
1175
+ }
1176
+ const result = await cliRunner.sendInput(session.cliMirrorId, value, profile, {
1177
+ ...sendOptions,
1178
+ attachments: sendOptions.attachments,
1179
+ });
1180
+ const mirror = cliRunner.getSession(session.cliMirrorId);
1181
+ if (mirror) session.turnPromise = mirror.turnPromise;
1182
+ return result;
1183
+ }
1184
+
1185
+ // Adopt a session that only exists in the CLI runner's store: the driver
1186
+ // session takes the SAME id so the sidebar handle stays stable and every
1187
+ // future turn stays pinned to the CLI transport.
1188
+ function adoptDiskSession(id) {
1189
+ const mirror = cliRunner.getSession(id);
1190
+ if (!mirror) return null;
1191
+ const session = {
1192
+ sessionId: id,
1193
+ projectSlug: mirror.projectSlug,
1194
+ projectPath: mirror.projectPath,
1195
+ state: 'idle',
1196
+ model: mirror.model || null,
1197
+ selectedModel: mirror.selectedModel || null,
1198
+ aiProfileId: mirror.aiProfileId || null,
1199
+ permissionMode: mirror.permissionMode || 'default',
1200
+ effort: mirror.effort || null,
1201
+ title: mirror.title || null,
1202
+ turns: Number(mirror.turns) || 0,
1203
+ startedAt: mirror.startedAt || new Date().toISOString(),
1204
+ endedAt: mirror.endedAt || null,
1205
+ pendingPermission: null,
1206
+ outputBuffer: [],
1207
+ eventSequence: 0,
1208
+ listeners: new Set(),
1209
+ opencodeSessionId: null, // the CLI thread id lives on the mirror
1210
+ currentTurn: null,
1211
+ lastResult: mirror.lastResult || '',
1212
+ lastDiag: '',
1213
+ usage: {
1214
+ inputTokens: mirror.usage ? mirror.usage.inputTokens : 0,
1215
+ outputTokens: mirror.usage ? mirror.usage.outputTokens : 0,
1216
+ cachedTokens: mirror.usage ? mirror.usage.cachedTokens : 0,
1217
+ },
1218
+ peakTurnTokens: mirror.peakTurnTokens || 0,
1219
+ turnUsage: null,
1220
+ cliMirrorId: id,
1221
+ mirrorAttached: false,
1222
+ pinnedCli: true,
1223
+ turnPromise: Promise.resolve(),
1224
+ };
1225
+ sessions.set(id, session);
1226
+ attachMirror(session);
1227
+ return session;
1228
+ }
1229
+
1230
+ function nextDriverSessionId() {
1231
+ const taken = new Set(sessions.keys());
1232
+ for (const item of cliRunner.listSessions()) taken.add(item.sessionId);
1233
+ const index = syncServeDiskIndex();
1234
+ if (index) for (const id of index.keys()) taken.add(id);
1235
+ let id = `opencode-${nextId++}`;
1236
+ while (taken.has(id)) id = `opencode-${nextId++}`;
1237
+ return id;
1238
+ }
1239
+
1240
+ return runner;
1241
+ }
1242
+
1243
+ function cliModelFor(profile) {
1244
+ const model = resolveOpenAIModel(profile);
1245
+ if (!model) return null;
1246
+ return /^[A-Za-z0-9._:/-]+$/.test(model) ? `${isInjected(profile) ? `${PROVIDER_ID}/` : ''}${model}` : null;
1247
+ }
1248
+
1249
+ // Workbench permission modes -> opencode per-session permission ruleset
1250
+ // (verified live against 1.18.5: a denied permission removes the tool from
1251
+ // the model's toolset, an allowed one permits it without asking).
1252
+ function permissionRulesetFor(permissionMode) {
1253
+ const unrestricted = permissionMode === 'bypassPermissions';
1254
+ const editable = unrestricted || permissionMode === 'acceptEdits';
1255
+ return [
1256
+ { permission: '*', pattern: '*', action: 'allow' },
1257
+ { permission: 'edit', pattern: '*', action: editable ? 'allow' : 'deny' },
1258
+ { permission: 'bash', pattern: '*', action: editable ? 'allow' : 'deny' },
1259
+ { permission: 'external_directory', pattern: '*', action: unrestricted ? 'allow' : 'deny' },
1260
+ { permission: 'question', pattern: '*', action: 'deny' },
1261
+ ];
1262
+ }
1263
+
1264
+ function parseOpenCodeLine(event, session, helpers) {
1265
+ const out = [];
1266
+ const type = event && event.type;
1267
+ const part = (event && event.part) || {};
1268
+ if (event && event.sessionID) helpers.setThreadId(event.sessionID);
1269
+ // Step boundaries flush any carried partial tag as literal text and start a
1270
+ // fresh parse context: a think block left open by a previous step (or turn)
1271
+ // must never swallow this step's text.
1272
+ if (type === 'step_start' || type === 'step_finish') {
1273
+ emitThinkTextPieces(flushThinkCarry(session), part, session, helpers, out);
1274
+ if (type === 'step_start') {
1275
+ resetThinkState(session);
1276
+ return out;
1277
+ }
1278
+ }
1279
+
1280
+ if (type === 'text' && part.text) {
1281
+ emitThinkTextPieces(parseThinkPieces(part.text, thinkStateOf(session)), part, session, helpers, out);
1282
+ return out;
1283
+ }
1284
+ if (type === 'reasoning' && part.text) {
1285
+ out.push({ type: 'claude/thinking-start', id: part.messageID || `think-${session.turns}` });
1286
+ out.push({ type: 'claude/thinking-delta', id: part.messageID || `think-${session.turns}`, text: part.text });
1287
+ return out;
1288
+ }
1289
+ if (type === 'tool_start' || type === 'tool_start.begin') {
1290
+ helpers.setState(session, 'tool-running');
1291
+ out.push({
1292
+ type: 'claude/tool-use',
1293
+ id: part.id || part.toolCallID || `tool-${session.outputBuffer.length}`,
1294
+ name: part.tool || 'tool',
1295
+ input: (part.state && part.state.input) || part.input || {},
1296
+ });
1297
+ return out;
1298
+ }
1299
+ if (type === 'tool_end' || type === 'tool_end.finish') {
1300
+ const output = (part.state && part.state.output) || part.output || '';
1301
+ out.push({
1302
+ type: 'claude/tool-result',
1303
+ id: part.id || part.toolCallID || `tool-${session.outputBuffer.length}`,
1304
+ output: String(output).slice(0, 4000),
1305
+ ...(part.state && part.state.metadata ? { metadata: part.state.metadata, diff: part.state.metadata.diff } : {}),
1306
+ });
1307
+ return out;
1308
+ }
1309
+ // opencode 1.18 emits one completed `tool_use` record per tool call.
1310
+ if (type === 'tool_use') {
1311
+ const state = part.state || {};
1312
+ const id = part.callID || part.id || `tool-${session.outputBuffer.length}`;
1313
+ helpers.setState(session, 'tool-running');
1314
+ out.push({ type: 'claude/tool-use', id, name: part.tool || 'tool', input: state.input || part.input || {} });
1315
+ if (state.status === 'completed' || state.status === 'error' || state.output != null) {
1316
+ out.push({ type: 'claude/tool-result', id, output: String(state.output || state.error || '').slice(0, 4000),
1317
+ isError: state.status === 'error', metadata: state.metadata, diff: state.metadata && state.metadata.diff });
1318
+ }
1319
+ return out;
1320
+ }
1321
+ if (type === 'step_finish') {
1322
+ const tokens = part.tokens || {};
1323
+ helpers.addUsage({ inputTokens: tokens.input, outputTokens: tokens.output, cachedTokens: tokens.cache && tokens.cache.read });
1324
+ // Step boundaries are not turn boundaries: the runner synthesizes the
1325
+ // single turn-level claude/result at process close.
1326
+ out.push({
1327
+ type: 'claude/usage',
1328
+ usage: { input_tokens: Number(tokens.input || 0), output_tokens: Number(tokens.output || 0) },
1329
+ });
1330
+ return out;
1331
+ }
1332
+ if (type === 'error') {
1333
+ const error = event.error || part.error;
1334
+ out.push({ type: 'claude/error', message: String(event.message || (error && error.data && error.data.message) || (error && error.message) || error || 'opencode error').slice(0, 500) });
1335
+ return out;
1336
+ }
1337
+ return out; // step_start and unknown types are ignored
1338
+ }
1339
+
1340
+ function isInjected(profile) { return Boolean(profile && profile.apiKey); }
1341
+
1342
+ // --- <think> stripping ------------------------------------------------------
1343
+ // OpenAI-compatible models routed through opencode (MiniMax and friends) wrap
1344
+ // their chain-of-thought in literal <think>…</think> inside text parts, which
1345
+ // used to land raw in the reply. Strip it session-wide: a think block can span
1346
+ // several parts, so the in/out state lives on the session; think content is
1347
+ // re-emitted as thinking events (the UI already folds those) and everything
1348
+ // outside flows through the normal text path.
1349
+ //
1350
+ // Text parts are incremental deltas, so a tag can be SPLIT across parts
1351
+ // ('</thi' + 'nk>'): a trailing partial tag is held back in state.carry and
1352
+ // re-joined with the next part. Step boundaries flush an unresolved carry as
1353
+ // literal text instead of dropping it. The serve transport feeds
1354
+ // message.part.delta events through the exact same state machine.
1355
+ const THINK_OPEN_TEXT = '<think>';
1356
+ const THINK_CLOSE_TEXT = '</think>';
1357
+ // Tolerate whitespace inside the tag ('</think >') — models emit it.
1358
+ const THINK_CLOSE_TAG = /<\/think\s*>/;
1359
+ const THINK_ANY_TAG = /<\/?think\s*>/;
1360
+ // Longest tail we may hold back: the tag name plus a few whitespace chars.
1361
+ const THINK_CARRY_LIMIT = 16;
1362
+
1363
+ function thinkStateOf(session) {
1364
+ if (!session._thinkState) {
1365
+ session._thinkState = { inThink: false, pendingStrip: false, carry: '', thinkBlock: 0, emittedBlock: 0 };
1366
+ }
1367
+ return session._thinkState;
1368
+ }
1369
+
1370
+ function resetThinkState(session) {
1371
+ const state = thinkStateOf(session);
1372
+ state.inThink = false;
1373
+ state.pendingStrip = false;
1374
+ state.carry = '';
1375
+ state.thinkBlock = 0;
1376
+ state.emittedBlock = 0;
1377
+ }
1378
+
1379
+ // Could `value` still grow into a complete tag once more deltas arrive?
1380
+ function couldGrowIntoThinkTag(value) {
1381
+ if (!value.startsWith('<')) return false;
1382
+ if (THINK_OPEN_TEXT.startsWith(value) || THINK_CLOSE_TEXT.startsWith(value)) return true;
1383
+ // Name complete, tag still waiting on '>' after whitespace: '<think '.
1384
+ return /^(<\/?think)\s*$/.test(value);
1385
+ }
1386
+
1387
+ // Longest suffix of `text` that could still grow into a tag.
1388
+ function thinkCarrySuffix(text) {
1389
+ for (let length = Math.min(text.length, THINK_CARRY_LIMIT); length >= 1; length -= 1) {
1390
+ const tail = text.slice(-length);
1391
+ if (couldGrowIntoThinkTag(tail)) return tail;
1392
+ }
1393
+ return '';
1394
+ }
1395
+
1396
+ // Splits one text part into ordered {think, text} pieces while mutating the
1397
+ // session state. `pendingStrip` removes the first whitespace run after a
1398
+ // closing tag — models open their reply with a blank line, which would
1399
+ // otherwise lead every answer with dead space.
1400
+ function parseThinkPieces(text, state, options = {}) {
1401
+ const holdPartialTag = options.holdPartialTag !== false;
1402
+ const pieces = [];
1403
+ let rest = state.carry + String(text);
1404
+ state.carry = '';
1405
+ const pushText = value => {
1406
+ if (!value) return;
1407
+ if (state.pendingStrip) {
1408
+ const stripped = value.replace(/^\s+/, '');
1409
+ if (!stripped) return; // whitespace-only run continues into the next part
1410
+ state.pendingStrip = false;
1411
+ pieces.push({ think: false, text: stripped });
1412
+ return;
1413
+ }
1414
+ pieces.push({ think: false, text: value });
1415
+ };
1416
+ const emitRun = value => {
1417
+ if (!value) return;
1418
+ if (state.inThink) pieces.push({ think: true, text: value, block: state.thinkBlock });
1419
+ else pushText(value);
1420
+ };
1421
+ while (rest) {
1422
+ const pattern = state.inThink ? THINK_CLOSE_TAG : THINK_ANY_TAG;
1423
+ const match = rest.match(pattern);
1424
+ if (!match) {
1425
+ const carry = holdPartialTag ? thinkCarrySuffix(rest) : '';
1426
+ emitRun(rest.slice(0, rest.length - carry.length));
1427
+ state.carry = carry;
1428
+ return pieces;
1429
+ }
1430
+ emitRun(rest.slice(0, match.index));
1431
+ if (state.inThink) {
1432
+ state.inThink = false;
1433
+ state.pendingStrip = true;
1434
+ } else if (match[0][1] === '/') {
1435
+ state.pendingStrip = true; // stray close without an open: swallow it
1436
+ } else {
1437
+ state.inThink = true;
1438
+ state.thinkBlock += 1; // a fresh block gets its own thinking-start
1439
+ }
1440
+ rest = rest.slice(match.index + match[0].length);
1441
+ }
1442
+ return pieces;
1443
+ }
1444
+
1445
+ // Emits parsed pieces in order. Each think BLOCK emits exactly one
1446
+ // thinking-start (piece granularity: two blocks inside one part both start);
1447
+ // a block continued across parts does not restart.
1448
+ function emitThinkTextPieces(pieces, part, session, helpers, out) {
1449
+ const state = thinkStateOf(session);
1450
+ let resultText = '';
1451
+ for (const piece of pieces) {
1452
+ if (!piece.text) continue;
1453
+ if (piece.think) {
1454
+ const id = part.messageID || `think-${session.turns}`;
1455
+ if (state.emittedBlock !== piece.block) {
1456
+ out.push({ type: 'claude/thinking-start', id });
1457
+ state.emittedBlock = piece.block;
1458
+ }
1459
+ out.push({ type: 'claude/thinking-delta', id, text: piece.text });
1460
+ continue;
1461
+ }
1462
+ const id = part.messageID || `text-${session.turns}`;
1463
+ out.push({ type: 'claude/text-start', id });
1464
+ out.push({ type: 'claude/text-delta', id, text: piece.text });
1465
+ resultText += piece.text;
1466
+ }
1467
+ // The turn result is the visible reply only; pure-thinking parts leave the
1468
+ // previous result untouched instead of overwriting it with ''.
1469
+ if (resultText) helpers.setResult(resultText);
1470
+ }
1471
+
1472
+ // No more deltas will arrive for this step: whatever partial tag is still
1473
+ // carried is literal text (or literal thinking), never a dropped character.
1474
+ function flushThinkCarry(session) {
1475
+ const state = thinkStateOf(session);
1476
+ if (!state.carry) return [];
1477
+ return parseThinkPieces('', state, { holdPartialTag: false });
1478
+ }
1479
+
1480
+ // ---- serve record persistence helpers (module level) ----------------------
1481
+ const SERVE_PERSIST_SCHEMA = 'opencode-serve-session/v1';
1482
+ const MAX_SERVE_PERSISTED_EVENTS = 5000;
1483
+
1484
+ function safeFileStem(value) {
1485
+ return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
1486
+ }
1487
+
1488
+ // Mirrors the CLI runner's resolution rules: explicit config always wins;
1489
+ // otherwise persistence is suppressed under the node:test runner. Injected
1490
+ // serve doubles (spawn/fetch) do NOT suppress persistence.
1491
+ function resolveServePersistence(options) {
1492
+ const configured = options.persistence && typeof options.persistence === 'object' ? options.persistence : {};
1493
+ const explicitlyConfigured = options.persistence != null;
1494
+ const ephemeralTestContext = Boolean(process.env.NODE_TEST_CONTEXT);
1495
+ const enabled = options.persistence !== null
1496
+ && configured.enabled !== false
1497
+ && (explicitlyConfigured || !ephemeralTestContext);
1498
+ const dir = path.resolve(configured.dir || path.join(os.homedir(), '.agent-terminal', 'cli-sessions'));
1499
+ return { enabled, dir };
1500
+ }
1501
+
1502
+ function opencodeCommandExists(command) {
1503
+ try {
1504
+ if (process.platform === 'win32') {
1505
+ const invocation = resolveCliInvocation(command, ['serve']);
1506
+ return path.isAbsolute(invocation.command);
1507
+ }
1508
+ if (path.isAbsolute(command)) return fs.existsSync(command);
1509
+ const dirs = (process.env.PATH || '').split(path.delimiter);
1510
+ return dirs.some(dir => {
1511
+ try { fs.accessSync(path.join(dir, command)); return true; } catch { return false; }
1512
+ });
1513
+ } catch {
1514
+ return false;
1515
+ }
1516
+ }
1517
+
1518
+ // Image materialization for the serve transport (file parts reference local
1519
+ // files; same on-disk discipline as the CLI runner: files exist only for the
1520
+ // duration of the turn).
1521
+ function prepareImages(projectPath, attachments = []) {
1522
+ const images = attachments.filter(item => item.mediaType && item.mediaType.startsWith('image/'));
1523
+ if (!images.length) return { imagePaths: [], cleanup() {} };
1524
+ const root = path.join(path.resolve(projectPath), '.agent-terminal-attachments');
1525
+ fs.mkdirSync(root, { recursive: true });
1526
+ // Refuse a workspace symlink/junction that would write attachments elsewhere.
1527
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error('attachment directory must not be a symbolic link');
1528
+ const turnDir = path.join(root, crypto.randomUUID());
1529
+ fs.mkdirSync(turnDir);
1530
+ let cleaned = false;
1531
+ const cleanup = () => {
1532
+ if (cleaned) return;
1533
+ fs.rmSync(turnDir, { recursive: true, force: true });
1534
+ try { fs.rmdirSync(root); } catch (error) {
1535
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
1536
+ }
1537
+ cleaned = true;
1538
+ };
1539
+ try {
1540
+ const imagePaths = images.map((item, index) => {
1541
+ const extension = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[item.mediaType];
1542
+ const file = path.join(turnDir, `${index}${extension}`);
1543
+ fs.writeFileSync(file, Buffer.from(item.data, 'base64'), { mode: 0o600 });
1544
+ return {
1545
+ path: file,
1546
+ // opencode file parts take a URL; local files use the file:// scheme.
1547
+ part: { type: 'file', mime: item.mediaType, filename: item.name || `image-${index}${extension}`, url: `file:///${file.replace(/\\/g, '/')}` },
1548
+ };
1549
+ });
1550
+ return { imagePaths, cleanup };
1551
+ } catch (error) { cleanup(); throw error; }
1552
+ }
1553
+
1554
+ module.exports = { createOpenCodeDriver, parseOpenCodeLine, permissionRulesetFor };