@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +652 -613
- package/apps/agent-terminal/app.js +143 -67
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +172 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +189 -159
- package/apps/agent-terminal/right-workspace-dock.js +652 -521
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +15 -13
- package/docs/sdk/openapi.yaml +16 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1461 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2408 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- 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 };
|