@ai-sdk/harness-opencode 0.0.0-cca10482-20260708215408
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/CHANGELOG.md +182 -0
- package/LICENSE +13 -0
- package/README.md +6 -0
- package/dist/bridge/host-tool-mcp.mjs +101 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +2194 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +13 -0
- package/dist/bridge/pnpm-lock.yaml +933 -0
- package/dist/index.d.ts +177 -0
- package/dist/index.js +999 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/bridge/host-tool-mcp.ts +134 -0
- package/src/bridge/index.ts +1993 -0
- package/src/bridge/opencode-events.ts +88 -0
- package/src/bridge/opencode-path.ts +17 -0
- package/src/bridge/opencode-usage.ts +156 -0
- package/src/bridge/package.json +13 -0
- package/src/bridge/pnpm-lock.yaml +933 -0
- package/src/bridge/tool-relay-auth.ts +151 -0
- package/src/index.ts +12 -0
- package/src/opencode-auth.ts +175 -0
- package/src/opencode-bridge-protocol.ts +29 -0
- package/src/opencode-harness.ts +1020 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,1993 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runBridge,
|
|
3
|
+
type BridgeEvent,
|
|
4
|
+
type BridgeTurn,
|
|
5
|
+
} from '@ai-sdk/harness/bridge';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import { createServer, type Server } from 'node:http';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { argv, env as procEnv } from 'node:process';
|
|
11
|
+
import type { StartMessage } from '../opencode-bridge-protocol';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createOpencodeClient,
|
|
15
|
+
createOpencodeServer,
|
|
16
|
+
} from '@opencode-ai/sdk/v2';
|
|
17
|
+
import {
|
|
18
|
+
emitMissingFinalDelta,
|
|
19
|
+
getOpenCodeEventSessionId,
|
|
20
|
+
isStepSettlementEvent,
|
|
21
|
+
type OpenCodeEvent,
|
|
22
|
+
unwrapOpenCodeEvent,
|
|
23
|
+
} from './opencode-events';
|
|
24
|
+
import { prependOpenCodeBinToPath } from './opencode-path';
|
|
25
|
+
import {
|
|
26
|
+
addUsage,
|
|
27
|
+
defaultUsage,
|
|
28
|
+
extractSessionTokens,
|
|
29
|
+
mapUsage,
|
|
30
|
+
subtractSessionTokens,
|
|
31
|
+
type HarnessUsage,
|
|
32
|
+
type OpenCodeTokenUsage,
|
|
33
|
+
} from './opencode-usage';
|
|
34
|
+
import {
|
|
35
|
+
ToolRelayAuthorizer,
|
|
36
|
+
isToolRelayRequestFromAllowedProcess,
|
|
37
|
+
type ToolRelayCall,
|
|
38
|
+
} from './tool-relay-auth';
|
|
39
|
+
|
|
40
|
+
type Emit = (msg: Record<string, unknown>) => void;
|
|
41
|
+
|
|
42
|
+
type OpenCodeClient = ReturnType<typeof createOpencodeClient>;
|
|
43
|
+
type OpenCodeServer = Awaited<ReturnType<typeof createOpencodeServer>>;
|
|
44
|
+
type ToolRelay = {
|
|
45
|
+
port: number;
|
|
46
|
+
close(): void;
|
|
47
|
+
authorizeToolCall(call: ToolRelayCall): void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type RuntimeState = {
|
|
51
|
+
server?: OpenCodeServer;
|
|
52
|
+
client?: OpenCodeClient;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
relay?: ToolRelay;
|
|
55
|
+
toolNames: Set<string>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type CommonBuiltinToolName =
|
|
59
|
+
| 'read'
|
|
60
|
+
| 'write'
|
|
61
|
+
| 'edit'
|
|
62
|
+
| 'bash'
|
|
63
|
+
| 'glob'
|
|
64
|
+
| 'grep';
|
|
65
|
+
|
|
66
|
+
const NATIVE_TO_COMMON: Readonly<Record<string, CommonBuiltinToolName>> = {
|
|
67
|
+
view: 'read',
|
|
68
|
+
read: 'read',
|
|
69
|
+
write: 'write',
|
|
70
|
+
edit: 'edit',
|
|
71
|
+
bash: 'bash',
|
|
72
|
+
glob: 'glob',
|
|
73
|
+
grep: 'grep',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const OPENCODE_TO_WIRE: Readonly<Record<string, string>> = {
|
|
77
|
+
list: 'ls',
|
|
78
|
+
ls: 'ls',
|
|
79
|
+
webfetch: 'webfetch',
|
|
80
|
+
task: 'agent',
|
|
81
|
+
agent: 'agent',
|
|
82
|
+
subtask: 'agent',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const PUBLIC_TO_NATIVE: Readonly<Record<string, string>> = {
|
|
86
|
+
read: 'view',
|
|
87
|
+
write: 'write',
|
|
88
|
+
edit: 'edit',
|
|
89
|
+
bash: 'bash',
|
|
90
|
+
glob: 'glob',
|
|
91
|
+
grep: 'grep',
|
|
92
|
+
ls: 'list',
|
|
93
|
+
webfetch: 'webfetch',
|
|
94
|
+
skill: 'skill',
|
|
95
|
+
todowrite: 'todowrite',
|
|
96
|
+
agent: 'agent',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const TOOL_KIND: Readonly<Record<string, 'readonly' | 'edit' | 'bash'>> = {
|
|
100
|
+
read: 'readonly',
|
|
101
|
+
glob: 'readonly',
|
|
102
|
+
grep: 'readonly',
|
|
103
|
+
ls: 'readonly',
|
|
104
|
+
webfetch: 'readonly',
|
|
105
|
+
write: 'edit',
|
|
106
|
+
edit: 'edit',
|
|
107
|
+
bash: 'bash',
|
|
108
|
+
agent: 'bash',
|
|
109
|
+
skill: 'edit',
|
|
110
|
+
todowrite: 'edit',
|
|
111
|
+
};
|
|
112
|
+
const HARNESS_CLIENT_APP = procEnv.AI_SDK_HARNESS_CLIENT_APP;
|
|
113
|
+
|
|
114
|
+
const args = parseArgs(argv.slice(2));
|
|
115
|
+
const workdir = args.workdir ?? emitFatal('Missing --workdir argument.');
|
|
116
|
+
const bridgeStateDir =
|
|
117
|
+
args.bridgeStateDir ?? emitFatal('Missing --bridge-state-dir argument.');
|
|
118
|
+
const bootstrapDir = args.bootstrapDir ?? workdir;
|
|
119
|
+
const skillsDir = args.skillsDir;
|
|
120
|
+
const runtime: RuntimeState = { toolNames: new Set() };
|
|
121
|
+
prependOpenCodeBinToPath({ bootstrapDir, env: procEnv });
|
|
122
|
+
|
|
123
|
+
mkdirSync(process.env.HOME ?? '/tmp/opencode-home', { recursive: true });
|
|
124
|
+
|
|
125
|
+
await runBridge<StartMessage>({
|
|
126
|
+
bridgeType: 'opencode',
|
|
127
|
+
bridgeStateDir,
|
|
128
|
+
onStart: runTurn,
|
|
129
|
+
onDetach: () =>
|
|
130
|
+
runtime.sessionId ? { openCodeSessionId: runtime.sessionId } : {},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
134
|
+
const emit: Emit = msg => turn.emit(msg as BridgeEvent);
|
|
135
|
+
let totalUsage: HarnessUsage | undefined;
|
|
136
|
+
try {
|
|
137
|
+
await ensureRuntime({ start, turn, emit });
|
|
138
|
+
const client = runtime.client!;
|
|
139
|
+
const sessionId = await ensureSession({ client, start, emit });
|
|
140
|
+
|
|
141
|
+
if (start.operation === 'compact') {
|
|
142
|
+
await runCompaction({ client, sessionId, start, turn, emit });
|
|
143
|
+
} else {
|
|
144
|
+
totalUsage = await runPrompt({ client, sessionId, start, turn, emit });
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
emit({ type: 'error', error: serialiseError(err) });
|
|
148
|
+
} finally {
|
|
149
|
+
emit({
|
|
150
|
+
type: 'finish',
|
|
151
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
152
|
+
totalUsage: totalUsage ?? defaultUsage(),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function ensureRuntime({
|
|
158
|
+
start,
|
|
159
|
+
turn,
|
|
160
|
+
emit,
|
|
161
|
+
}: {
|
|
162
|
+
start: StartMessage;
|
|
163
|
+
turn: BridgeTurn;
|
|
164
|
+
emit: Emit;
|
|
165
|
+
}): Promise<void> {
|
|
166
|
+
if (runtime.client) return;
|
|
167
|
+
|
|
168
|
+
if (start.tools && start.tools.length > 0) {
|
|
169
|
+
runtime.toolNames = new Set(start.tools.map(tool => tool.name));
|
|
170
|
+
runtime.relay = await startToolRelay({
|
|
171
|
+
allowedScriptPaths: [`${bootstrapDir}/host-tool-mcp.mjs`],
|
|
172
|
+
tools: start.tools,
|
|
173
|
+
emit,
|
|
174
|
+
requestToolResult: turn.requestToolResult,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const server = await createOpencodeServer({
|
|
179
|
+
hostname: '127.0.0.1',
|
|
180
|
+
port: 0,
|
|
181
|
+
timeout: 30_000,
|
|
182
|
+
config: buildOpenCodeConfig({
|
|
183
|
+
start,
|
|
184
|
+
relayPort: runtime.relay?.port,
|
|
185
|
+
}) as never,
|
|
186
|
+
});
|
|
187
|
+
runtime.server = server;
|
|
188
|
+
runtime.client = createOpencodeClient({
|
|
189
|
+
baseUrl: server.url,
|
|
190
|
+
directory: workdir,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildOpenCodeConfig({
|
|
195
|
+
start,
|
|
196
|
+
relayPort,
|
|
197
|
+
}: {
|
|
198
|
+
start: StartMessage;
|
|
199
|
+
relayPort: number | undefined;
|
|
200
|
+
}): Record<string, unknown> {
|
|
201
|
+
const config: Record<string, unknown> = {
|
|
202
|
+
share: 'disabled',
|
|
203
|
+
autoupdate: false,
|
|
204
|
+
permission: {
|
|
205
|
+
read: 'allow',
|
|
206
|
+
glob: 'allow',
|
|
207
|
+
grep: 'allow',
|
|
208
|
+
list: 'allow',
|
|
209
|
+
edit: 'ask',
|
|
210
|
+
bash: 'ask',
|
|
211
|
+
external_directory: 'ask',
|
|
212
|
+
webfetch: 'ask',
|
|
213
|
+
doom_loop: 'ask',
|
|
214
|
+
task: 'ask',
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
if (start.model) config.model = start.model;
|
|
218
|
+
if (skillsDir) config.skills = { paths: [skillsDir] };
|
|
219
|
+
const inactiveToolNames = resolveInactiveBuiltinToolNames(start);
|
|
220
|
+
const permission = config.permission as Record<string, unknown>;
|
|
221
|
+
for (const toolName of inactiveToolNames) {
|
|
222
|
+
const permissionName = toPermissionToolName(
|
|
223
|
+
PUBLIC_TO_NATIVE[toolName] ?? toolName,
|
|
224
|
+
);
|
|
225
|
+
if (permissionName === 'ls') {
|
|
226
|
+
permission.list = 'ask';
|
|
227
|
+
} else {
|
|
228
|
+
permission[permissionName] = 'ask';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const provider = buildProviderConfig(start);
|
|
232
|
+
if (provider) config.provider = provider;
|
|
233
|
+
if (relayPort && start.tools && start.tools.length > 0) {
|
|
234
|
+
config.mcp = {
|
|
235
|
+
'harness-tools': {
|
|
236
|
+
type: 'local',
|
|
237
|
+
enabled: true,
|
|
238
|
+
command: ['node', `${bootstrapDir}/host-tool-mcp.mjs`],
|
|
239
|
+
environment: {
|
|
240
|
+
TOOL_SCHEMAS: JSON.stringify(
|
|
241
|
+
start.tools.map(t => ({
|
|
242
|
+
name: t.name,
|
|
243
|
+
description: t.description,
|
|
244
|
+
inputSchema: t.inputSchema,
|
|
245
|
+
})),
|
|
246
|
+
),
|
|
247
|
+
TOOL_RELAY_URL: `http://127.0.0.1:${relayPort}`,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return config;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildProviderConfig(
|
|
256
|
+
start: StartMessage,
|
|
257
|
+
): Record<string, unknown> | undefined {
|
|
258
|
+
const model = splitModel(start.model, start.provider);
|
|
259
|
+
const providerID =
|
|
260
|
+
model.providerID ?? start.provider ?? procEnv.OPENAI_NAME ?? 'anthropic';
|
|
261
|
+
const modelID = model.modelID;
|
|
262
|
+
|
|
263
|
+
if (procEnv.AI_GATEWAY_API_KEY && procEnv.AI_GATEWAY_BASE_URL) {
|
|
264
|
+
return {
|
|
265
|
+
[providerID]: {
|
|
266
|
+
options: {
|
|
267
|
+
apiKey: procEnv.AI_GATEWAY_API_KEY,
|
|
268
|
+
baseURL: toOpenCodeGatewayBaseUrl(procEnv.AI_GATEWAY_BASE_URL),
|
|
269
|
+
...(HARNESS_CLIENT_APP
|
|
270
|
+
? { headers: { 'x-client-app': HARNESS_CLIENT_APP } }
|
|
271
|
+
: {}),
|
|
272
|
+
},
|
|
273
|
+
...(modelID
|
|
274
|
+
? {
|
|
275
|
+
models: {
|
|
276
|
+
[modelID]: { id: modelID, name: modelID },
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
: {}),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
(procEnv.OPENAI_NAME ||
|
|
286
|
+
(providerID !== 'anthropic' && providerID !== 'openai')) &&
|
|
287
|
+
(procEnv.OPENAI_API_KEY || procEnv.OPENAI_BASE_URL)
|
|
288
|
+
) {
|
|
289
|
+
const openAICompatibleProviderID = procEnv.OPENAI_NAME ?? providerID;
|
|
290
|
+
return {
|
|
291
|
+
[openAICompatibleProviderID]: {
|
|
292
|
+
options: {
|
|
293
|
+
...(procEnv.OPENAI_API_KEY ? { apiKey: procEnv.OPENAI_API_KEY } : {}),
|
|
294
|
+
...(procEnv.OPENAI_BASE_URL
|
|
295
|
+
? { baseURL: procEnv.OPENAI_BASE_URL }
|
|
296
|
+
: {}),
|
|
297
|
+
...parseOpenAIQueryParams(),
|
|
298
|
+
},
|
|
299
|
+
...(modelID
|
|
300
|
+
? {
|
|
301
|
+
models: {
|
|
302
|
+
[modelID]: { id: modelID, name: modelID },
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
: {}),
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (
|
|
311
|
+
providerID === 'anthropic' &&
|
|
312
|
+
(procEnv.ANTHROPIC_API_KEY ||
|
|
313
|
+
procEnv.ANTHROPIC_AUTH_TOKEN ||
|
|
314
|
+
procEnv.ANTHROPIC_BASE_URL)
|
|
315
|
+
) {
|
|
316
|
+
return {
|
|
317
|
+
anthropic: {
|
|
318
|
+
options: {
|
|
319
|
+
...(procEnv.ANTHROPIC_API_KEY
|
|
320
|
+
? { apiKey: procEnv.ANTHROPIC_API_KEY }
|
|
321
|
+
: {}),
|
|
322
|
+
...(procEnv.ANTHROPIC_AUTH_TOKEN
|
|
323
|
+
? { authToken: procEnv.ANTHROPIC_AUTH_TOKEN }
|
|
324
|
+
: {}),
|
|
325
|
+
...(procEnv.ANTHROPIC_BASE_URL
|
|
326
|
+
? { baseURL: procEnv.ANTHROPIC_BASE_URL }
|
|
327
|
+
: {}),
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (
|
|
334
|
+
providerID === 'openai' &&
|
|
335
|
+
(procEnv.OPENAI_API_KEY || procEnv.OPENAI_BASE_URL)
|
|
336
|
+
) {
|
|
337
|
+
return {
|
|
338
|
+
openai: {
|
|
339
|
+
options: {
|
|
340
|
+
...(procEnv.OPENAI_API_KEY ? { apiKey: procEnv.OPENAI_API_KEY } : {}),
|
|
341
|
+
...(procEnv.OPENAI_BASE_URL
|
|
342
|
+
? { baseURL: procEnv.OPENAI_BASE_URL }
|
|
343
|
+
: {}),
|
|
344
|
+
...(procEnv.OPENAI_ORGANIZATION
|
|
345
|
+
? { organization: procEnv.OPENAI_ORGANIZATION }
|
|
346
|
+
: {}),
|
|
347
|
+
...(procEnv.OPENAI_PROJECT
|
|
348
|
+
? { project: procEnv.OPENAI_PROJECT }
|
|
349
|
+
: {}),
|
|
350
|
+
...parseOpenAIQueryParams(),
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function parseOpenAIQueryParams(): Record<string, unknown> {
|
|
360
|
+
if (!procEnv.OPENAI_QUERY_PARAMS_JSON) return {};
|
|
361
|
+
try {
|
|
362
|
+
const parsed = JSON.parse(procEnv.OPENAI_QUERY_PARAMS_JSON);
|
|
363
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
364
|
+
return { queryParams: parsed };
|
|
365
|
+
}
|
|
366
|
+
} catch {}
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function toOpenCodeGatewayBaseUrl(baseUrl: string): string {
|
|
371
|
+
const trimmed = baseUrl.replace(/\/+$/, '');
|
|
372
|
+
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function legacySessionGet({
|
|
376
|
+
client,
|
|
377
|
+
sessionId,
|
|
378
|
+
}: {
|
|
379
|
+
client: OpenCodeClient;
|
|
380
|
+
sessionId: string;
|
|
381
|
+
}): Promise<{ error?: unknown; data?: unknown }> {
|
|
382
|
+
const session = (client as any).session;
|
|
383
|
+
if (!session?.get) return client.v2.session.get({ sessionID: sessionId });
|
|
384
|
+
return session.get({ sessionID: sessionId });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function legacySessionCreate({
|
|
388
|
+
client,
|
|
389
|
+
}: {
|
|
390
|
+
client: OpenCodeClient;
|
|
391
|
+
}): Promise<{ error?: unknown; data?: unknown }> {
|
|
392
|
+
return (client as any).session.create({});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function legacySessionPrompt({
|
|
396
|
+
client,
|
|
397
|
+
sessionId,
|
|
398
|
+
start,
|
|
399
|
+
}: {
|
|
400
|
+
client: OpenCodeClient;
|
|
401
|
+
sessionId: string;
|
|
402
|
+
start: StartMessage;
|
|
403
|
+
}): Promise<{ error?: unknown; data?: unknown }> {
|
|
404
|
+
return (client as any).session.prompt({
|
|
405
|
+
sessionID: sessionId,
|
|
406
|
+
...(start.instructions ? { system: start.instructions } : {}),
|
|
407
|
+
...(start.variant ? { variant: start.variant } : {}),
|
|
408
|
+
parts: [{ type: 'text', text: start.prompt }],
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function legacySessionSummarize({
|
|
413
|
+
client,
|
|
414
|
+
sessionId,
|
|
415
|
+
model,
|
|
416
|
+
}: {
|
|
417
|
+
client: OpenCodeClient;
|
|
418
|
+
sessionId: string;
|
|
419
|
+
model: OpenCodeModelRef;
|
|
420
|
+
}): Promise<{ error?: unknown; data?: unknown }> {
|
|
421
|
+
return (client as any).session.summarize({
|
|
422
|
+
sessionID: sessionId,
|
|
423
|
+
auto: false,
|
|
424
|
+
providerID: model.providerID,
|
|
425
|
+
modelID: model.modelID,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function subscribeLegacyEvents({
|
|
430
|
+
client,
|
|
431
|
+
signal,
|
|
432
|
+
}: {
|
|
433
|
+
client: OpenCodeClient;
|
|
434
|
+
signal: AbortSignal;
|
|
435
|
+
}): Promise<AsyncIterable<unknown> | null> {
|
|
436
|
+
const subscribed = await (client as any).event.subscribe(undefined, {
|
|
437
|
+
signal,
|
|
438
|
+
sseMaxRetryAttempts: 0,
|
|
439
|
+
});
|
|
440
|
+
return getEventStream(subscribed);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function readSessionId(data: unknown): string | undefined {
|
|
444
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
445
|
+
const record = data as { id?: unknown; data?: { id?: unknown } };
|
|
446
|
+
if (typeof record.id === 'string') return record.id;
|
|
447
|
+
if (typeof record.data?.id === 'string') return record.data.id;
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
452
|
+
return (
|
|
453
|
+
typeof value === 'object' && value !== null && Symbol.asyncIterator in value
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getEventStream(source: unknown): AsyncIterable<unknown> | null {
|
|
458
|
+
if (!source || typeof source !== 'object') return null;
|
|
459
|
+
const candidate = source as { stream?: unknown; data?: unknown };
|
|
460
|
+
if (isAsyncIterable(candidate.stream)) return candidate.stream;
|
|
461
|
+
if (isAsyncIterable(candidate.data)) return candidate.data;
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function legacyStatusType(event: OpenCodeEvent): string | undefined {
|
|
466
|
+
const status = event.properties?.status;
|
|
467
|
+
return status && typeof status === 'object'
|
|
468
|
+
? String((status as { type?: unknown }).type ?? '')
|
|
469
|
+
: undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function legacyStatusMessage(event: OpenCodeEvent): string | undefined {
|
|
473
|
+
const status = event.properties?.status;
|
|
474
|
+
if (!status || typeof status !== 'object') return undefined;
|
|
475
|
+
const message = (status as { message?: unknown }).message;
|
|
476
|
+
return typeof message === 'string' ? message : undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function ensureSession({
|
|
480
|
+
client,
|
|
481
|
+
start,
|
|
482
|
+
emit,
|
|
483
|
+
}: {
|
|
484
|
+
client: OpenCodeClient;
|
|
485
|
+
start: StartMessage;
|
|
486
|
+
emit: Emit;
|
|
487
|
+
}): Promise<string> {
|
|
488
|
+
if (runtime.sessionId) return runtime.sessionId;
|
|
489
|
+
if (start.resumeSessionId) {
|
|
490
|
+
const existing = await legacySessionGet({
|
|
491
|
+
client,
|
|
492
|
+
sessionId: start.resumeSessionId,
|
|
493
|
+
}).catch(() => undefined);
|
|
494
|
+
if (existing && !existing.error) {
|
|
495
|
+
runtime.sessionId = start.resumeSessionId;
|
|
496
|
+
emit({ type: 'bridge-thread', threadId: runtime.sessionId });
|
|
497
|
+
return runtime.sessionId;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const created = await legacySessionCreate({ client });
|
|
501
|
+
if (created.error) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`OpenCode session create failed: ${formatError(created.error)}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const id = readSessionId(created.data);
|
|
507
|
+
if (!id) throw new Error('OpenCode session create returned no id.');
|
|
508
|
+
runtime.sessionId = id;
|
|
509
|
+
emit({ type: 'bridge-thread', threadId: id });
|
|
510
|
+
return id;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function runPrompt({
|
|
514
|
+
client,
|
|
515
|
+
sessionId,
|
|
516
|
+
start,
|
|
517
|
+
turn,
|
|
518
|
+
emit,
|
|
519
|
+
}: {
|
|
520
|
+
client: OpenCodeClient;
|
|
521
|
+
sessionId: string;
|
|
522
|
+
start: StartMessage;
|
|
523
|
+
turn: BridgeTurn;
|
|
524
|
+
emit: Emit;
|
|
525
|
+
}): Promise<HarnessUsage | undefined> {
|
|
526
|
+
const eventsAbort = new AbortController();
|
|
527
|
+
const turnSettled = createDeferred<void>();
|
|
528
|
+
let sawContent = false;
|
|
529
|
+
let sawFinishStep = false;
|
|
530
|
+
let sawBusy = false;
|
|
531
|
+
let terminalError: string | undefined;
|
|
532
|
+
const initialSessionTokens = await readSessionTokens({
|
|
533
|
+
client,
|
|
534
|
+
sessionId,
|
|
535
|
+
}).catch(() => undefined);
|
|
536
|
+
let stepUsage: HarnessUsage | undefined;
|
|
537
|
+
let latestSessionTokens: OpenCodeTokenUsage | undefined;
|
|
538
|
+
const eventLoop = consumeEvents({
|
|
539
|
+
client,
|
|
540
|
+
sessionId,
|
|
541
|
+
permissionMode: start.permissionMode,
|
|
542
|
+
builtinToolFiltering: start.builtinToolFiltering,
|
|
543
|
+
turn,
|
|
544
|
+
emit: msg => {
|
|
545
|
+
if (msg.type === 'text-delta' || msg.type === 'reasoning-delta') {
|
|
546
|
+
sawContent = true;
|
|
547
|
+
}
|
|
548
|
+
if (msg.type === 'finish-step') {
|
|
549
|
+
sawFinishStep = true;
|
|
550
|
+
stepUsage = addUsage({
|
|
551
|
+
left: stepUsage,
|
|
552
|
+
right: msg.usage as HarnessUsage,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
emit(msg);
|
|
556
|
+
},
|
|
557
|
+
signal: eventsAbort.signal,
|
|
558
|
+
onEvent: event => {
|
|
559
|
+
if (event.type === 'session.updated') {
|
|
560
|
+
latestSessionTokens =
|
|
561
|
+
extractSessionTokens(event.properties) ?? latestSessionTokens;
|
|
562
|
+
}
|
|
563
|
+
if (isStepSettlementEvent(event)) {
|
|
564
|
+
turnSettled.resolve();
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
const status = legacyStatusType(event);
|
|
568
|
+
if (status === 'busy') {
|
|
569
|
+
sawBusy = true;
|
|
570
|
+
} else if (status === 'retry') {
|
|
571
|
+
sawBusy = true;
|
|
572
|
+
terminalError = legacyStatusMessage(event) ?? 'Session retry';
|
|
573
|
+
turnSettled.resolve();
|
|
574
|
+
return true;
|
|
575
|
+
} else if (sawBusy && status === 'idle') {
|
|
576
|
+
turnSettled.resolve();
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
if (event.type === 'session.error') {
|
|
580
|
+
terminalError = formatError(event.properties?.error ?? event);
|
|
581
|
+
turnSettled.resolve();
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
}).finally(() => turnSettled.resolve());
|
|
586
|
+
emit({
|
|
587
|
+
type: 'stream-start',
|
|
588
|
+
...(start.model ? { modelId: start.model } : {}),
|
|
589
|
+
});
|
|
590
|
+
const prompted = await legacySessionPrompt({
|
|
591
|
+
client,
|
|
592
|
+
sessionId,
|
|
593
|
+
start,
|
|
594
|
+
});
|
|
595
|
+
if (prompted.error) {
|
|
596
|
+
eventsAbort.abort();
|
|
597
|
+
throw new Error(`OpenCode prompt failed: ${formatError(prompted.error)}`);
|
|
598
|
+
}
|
|
599
|
+
await turnSettled.promise;
|
|
600
|
+
eventsAbort.abort();
|
|
601
|
+
await eventLoop.catch(() => {});
|
|
602
|
+
if (terminalError) throw new Error(terminalError);
|
|
603
|
+
if (!sawFinishStep) {
|
|
604
|
+
const emittedFallback = await emitContextFallback({
|
|
605
|
+
client,
|
|
606
|
+
sessionId,
|
|
607
|
+
emit,
|
|
608
|
+
emitContent: !sawContent,
|
|
609
|
+
}).catch(() => false);
|
|
610
|
+
if (!emittedFallback) {
|
|
611
|
+
emit({
|
|
612
|
+
type: 'finish-step',
|
|
613
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
614
|
+
usage: defaultUsage(),
|
|
615
|
+
harnessMetadata: { opencode: { fallback: true, missingContext: true } },
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const finalSessionTokens =
|
|
620
|
+
(await readSessionTokens({ client, sessionId }).catch(() => undefined)) ??
|
|
621
|
+
latestSessionTokens;
|
|
622
|
+
if (initialSessionTokens && finalSessionTokens) {
|
|
623
|
+
return mapUsage(
|
|
624
|
+
subtractSessionTokens({
|
|
625
|
+
before: initialSessionTokens,
|
|
626
|
+
after: finalSessionTokens,
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return stepUsage;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function runCompaction({
|
|
634
|
+
client,
|
|
635
|
+
sessionId,
|
|
636
|
+
start,
|
|
637
|
+
turn,
|
|
638
|
+
emit,
|
|
639
|
+
}: {
|
|
640
|
+
client: OpenCodeClient;
|
|
641
|
+
sessionId: string;
|
|
642
|
+
start: StartMessage;
|
|
643
|
+
turn: BridgeTurn;
|
|
644
|
+
emit: Emit;
|
|
645
|
+
}): Promise<void> {
|
|
646
|
+
const eventsAbort = new AbortController();
|
|
647
|
+
const compactionSettled = createDeferred<void>();
|
|
648
|
+
let sawCompaction = false;
|
|
649
|
+
let sawBusy = false;
|
|
650
|
+
let terminalError: string | undefined;
|
|
651
|
+
const model = await resolveCompactionModel({
|
|
652
|
+
client,
|
|
653
|
+
sessionId,
|
|
654
|
+
start,
|
|
655
|
+
});
|
|
656
|
+
if (!model) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
'OpenCode compaction requires a previous turn or an explicit model.',
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
const eventLoop = consumeEvents({
|
|
662
|
+
client,
|
|
663
|
+
sessionId,
|
|
664
|
+
permissionMode: start.permissionMode,
|
|
665
|
+
builtinToolFiltering: start.builtinToolFiltering,
|
|
666
|
+
turn,
|
|
667
|
+
emit: msg => {
|
|
668
|
+
if (msg.type === 'compaction') sawCompaction = true;
|
|
669
|
+
emit(msg);
|
|
670
|
+
},
|
|
671
|
+
signal: eventsAbort.signal,
|
|
672
|
+
onEvent: event => {
|
|
673
|
+
if (
|
|
674
|
+
event.type === 'session.next.compaction.ended' ||
|
|
675
|
+
event.type === 'session.compacted'
|
|
676
|
+
) {
|
|
677
|
+
compactionSettled.resolve();
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
const status = legacyStatusType(event);
|
|
681
|
+
if (status === 'busy') {
|
|
682
|
+
sawBusy = true;
|
|
683
|
+
} else if (status === 'retry') {
|
|
684
|
+
sawBusy = true;
|
|
685
|
+
terminalError = legacyStatusMessage(event) ?? 'Session retry';
|
|
686
|
+
compactionSettled.resolve();
|
|
687
|
+
return true;
|
|
688
|
+
} else if (sawBusy && status === 'idle') {
|
|
689
|
+
compactionSettled.resolve();
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
if (event.type === 'session.error') {
|
|
693
|
+
terminalError = formatError(event.properties?.error ?? event);
|
|
694
|
+
compactionSettled.resolve();
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
const compacted = await legacySessionSummarize({
|
|
700
|
+
client,
|
|
701
|
+
sessionId,
|
|
702
|
+
model,
|
|
703
|
+
});
|
|
704
|
+
if (compacted.error) {
|
|
705
|
+
eventsAbort.abort();
|
|
706
|
+
throw new Error(
|
|
707
|
+
`OpenCode compaction failed: ${formatError(compacted.error)}`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
await Promise.race([compactionSettled.promise, sleep(250)]);
|
|
711
|
+
eventsAbort.abort();
|
|
712
|
+
await eventLoop.catch(() => {});
|
|
713
|
+
if (terminalError) throw new Error(terminalError);
|
|
714
|
+
if (!sawCompaction) {
|
|
715
|
+
emit({
|
|
716
|
+
type: 'compaction',
|
|
717
|
+
trigger: 'manual',
|
|
718
|
+
summary: '',
|
|
719
|
+
harnessMetadata: {
|
|
720
|
+
opencode: { missingSummary: true },
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function consumeEvents({
|
|
727
|
+
client,
|
|
728
|
+
sessionId,
|
|
729
|
+
permissionMode,
|
|
730
|
+
builtinToolFiltering,
|
|
731
|
+
turn,
|
|
732
|
+
emit,
|
|
733
|
+
signal,
|
|
734
|
+
onEvent,
|
|
735
|
+
}: {
|
|
736
|
+
client: OpenCodeClient;
|
|
737
|
+
sessionId: string;
|
|
738
|
+
permissionMode: StartMessage['permissionMode'];
|
|
739
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
740
|
+
turn: BridgeTurn;
|
|
741
|
+
emit: Emit;
|
|
742
|
+
signal: AbortSignal;
|
|
743
|
+
onEvent?: (event: OpenCodeEvent) => boolean | void;
|
|
744
|
+
}): Promise<void> {
|
|
745
|
+
const stream = await subscribeLegacyEvents({ client, signal });
|
|
746
|
+
if (!stream) return;
|
|
747
|
+
const state = createTranslationState();
|
|
748
|
+
for await (const rawEvent of stream) {
|
|
749
|
+
if (signal.aborted || turn.abortSignal.aborted) break;
|
|
750
|
+
const event = unwrapOpenCodeEvent(rawEvent);
|
|
751
|
+
const eventSessionId = event ? getOpenCodeEventSessionId(event) : undefined;
|
|
752
|
+
if (!event || (eventSessionId && eventSessionId !== sessionId)) continue;
|
|
753
|
+
await translateAndEmit({
|
|
754
|
+
event,
|
|
755
|
+
state,
|
|
756
|
+
sessionId,
|
|
757
|
+
permissionMode,
|
|
758
|
+
builtinToolFiltering,
|
|
759
|
+
client,
|
|
760
|
+
turn,
|
|
761
|
+
emit,
|
|
762
|
+
});
|
|
763
|
+
if (onEvent?.(event)) break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
type TranslationState = {
|
|
768
|
+
textDeltas: Map<string, string>;
|
|
769
|
+
reasoningDeltas: Map<string, string>;
|
|
770
|
+
toolInputs: Map<string, string>;
|
|
771
|
+
toolNames: Map<string, { rawToolName: string; toolName: string }>;
|
|
772
|
+
toolCallsEmitted: Set<string>;
|
|
773
|
+
toolResultsEmitted: Set<string>;
|
|
774
|
+
hostToolCallsAuthorized: Set<string>;
|
|
775
|
+
shellCommands: Map<string, string>;
|
|
776
|
+
messageRoles: Map<string, string>;
|
|
777
|
+
turnUsage: Record<string, unknown> | undefined;
|
|
778
|
+
legacyTextPartIds: Set<string>;
|
|
779
|
+
legacyReasoningPartIds: Set<string>;
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
function createTranslationState(): TranslationState {
|
|
783
|
+
return {
|
|
784
|
+
textDeltas: new Map(),
|
|
785
|
+
reasoningDeltas: new Map(),
|
|
786
|
+
toolInputs: new Map(),
|
|
787
|
+
toolNames: new Map(),
|
|
788
|
+
toolCallsEmitted: new Set(),
|
|
789
|
+
toolResultsEmitted: new Set(),
|
|
790
|
+
hostToolCallsAuthorized: new Set(),
|
|
791
|
+
shellCommands: new Map(),
|
|
792
|
+
messageRoles: new Map(),
|
|
793
|
+
turnUsage: undefined,
|
|
794
|
+
legacyTextPartIds: new Set(),
|
|
795
|
+
legacyReasoningPartIds: new Set(),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function translateAndEmit({
|
|
800
|
+
event,
|
|
801
|
+
state,
|
|
802
|
+
sessionId,
|
|
803
|
+
permissionMode,
|
|
804
|
+
builtinToolFiltering,
|
|
805
|
+
client,
|
|
806
|
+
turn,
|
|
807
|
+
emit,
|
|
808
|
+
}: {
|
|
809
|
+
event: OpenCodeEvent;
|
|
810
|
+
state: TranslationState;
|
|
811
|
+
sessionId: string;
|
|
812
|
+
permissionMode: StartMessage['permissionMode'];
|
|
813
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
814
|
+
client: OpenCodeClient;
|
|
815
|
+
turn: BridgeTurn;
|
|
816
|
+
emit: Emit;
|
|
817
|
+
}): Promise<void> {
|
|
818
|
+
const type = event.type;
|
|
819
|
+
const props = event.properties ?? {};
|
|
820
|
+
|
|
821
|
+
if (type === 'message.updated') {
|
|
822
|
+
const info = props.info;
|
|
823
|
+
if (isRecord(info)) {
|
|
824
|
+
const id = stringValue(info.id);
|
|
825
|
+
const role = stringValue(info.role);
|
|
826
|
+
if (id && role) state.messageRoles.set(id, role);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (type === 'message.part.delta') {
|
|
832
|
+
const field = String(props.field ?? '');
|
|
833
|
+
const delta = String(props.delta ?? '');
|
|
834
|
+
if (!delta) return;
|
|
835
|
+
const messageID = stringValue(props.messageID);
|
|
836
|
+
if (messageID && state.messageRoles.get(messageID) === 'user') return;
|
|
837
|
+
if (field === 'text') {
|
|
838
|
+
const id = legacyPartId({ value: props, fallback: 'legacy-text' });
|
|
839
|
+
startLegacyPart({ ids: state.legacyTextPartIds, id, emit, type: 'text' });
|
|
840
|
+
state.textDeltas.set(id, `${state.textDeltas.get(id) ?? ''}${delta}`);
|
|
841
|
+
emit({ type: 'text-delta', id, delta });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (field === 'reasoning') {
|
|
845
|
+
const id = legacyPartId({ value: props, fallback: 'legacy-reasoning' });
|
|
846
|
+
startLegacyPart({
|
|
847
|
+
ids: state.legacyReasoningPartIds,
|
|
848
|
+
id,
|
|
849
|
+
emit,
|
|
850
|
+
type: 'reasoning',
|
|
851
|
+
});
|
|
852
|
+
state.reasoningDeltas.set(
|
|
853
|
+
id,
|
|
854
|
+
`${state.reasoningDeltas.get(id) ?? ''}${delta}`,
|
|
855
|
+
);
|
|
856
|
+
emit({ type: 'reasoning-delta', id, delta });
|
|
857
|
+
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (type === 'message.part.updated') {
|
|
862
|
+
if (emitLegacyTextPartUpdate({ part: props.part, state, emit })) return;
|
|
863
|
+
emitLegacyToolPart({ part: props.part, state, emit });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (type === 'session.next.text.started') {
|
|
868
|
+
emit({ type: 'text-start', id: String(props.textID ?? event.id) });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (type === 'session.next.text.delta') {
|
|
872
|
+
const id = String(props.textID ?? event.id);
|
|
873
|
+
state.textDeltas.set(
|
|
874
|
+
id,
|
|
875
|
+
`${state.textDeltas.get(id) ?? ''}${String(props.delta ?? '')}`,
|
|
876
|
+
);
|
|
877
|
+
emit({
|
|
878
|
+
type: 'text-delta',
|
|
879
|
+
id,
|
|
880
|
+
delta: String(props.delta ?? ''),
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (type === 'session.next.text.ended') {
|
|
885
|
+
const id = String(props.textID ?? event.id);
|
|
886
|
+
emitMissingFinalDelta({
|
|
887
|
+
id,
|
|
888
|
+
fullText: typeof props.text === 'string' ? props.text : undefined,
|
|
889
|
+
emittedText: state.textDeltas.get(id) ?? '',
|
|
890
|
+
emit,
|
|
891
|
+
type: 'text-delta',
|
|
892
|
+
});
|
|
893
|
+
emit({ type: 'text-end', id });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (type === 'session.next.reasoning.started') {
|
|
897
|
+
emit({
|
|
898
|
+
type: 'reasoning-start',
|
|
899
|
+
id: String(props.reasoningID ?? event.id),
|
|
900
|
+
});
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (type === 'session.next.reasoning.delta') {
|
|
904
|
+
const id = String(props.reasoningID ?? event.id);
|
|
905
|
+
state.reasoningDeltas.set(
|
|
906
|
+
id,
|
|
907
|
+
`${state.reasoningDeltas.get(id) ?? ''}${String(props.delta ?? '')}`,
|
|
908
|
+
);
|
|
909
|
+
emit({
|
|
910
|
+
type: 'reasoning-delta',
|
|
911
|
+
id,
|
|
912
|
+
delta: String(props.delta ?? ''),
|
|
913
|
+
});
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (type === 'session.next.reasoning.ended') {
|
|
917
|
+
const id = String(props.reasoningID ?? event.id);
|
|
918
|
+
emitMissingFinalDelta({
|
|
919
|
+
id,
|
|
920
|
+
fullText: typeof props.text === 'string' ? props.text : undefined,
|
|
921
|
+
emittedText: state.reasoningDeltas.get(id) ?? '',
|
|
922
|
+
emit,
|
|
923
|
+
type: 'reasoning-delta',
|
|
924
|
+
});
|
|
925
|
+
emit({ type: 'reasoning-end', id });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (type === 'session.next.shell.started') {
|
|
929
|
+
const callID = String(props.callID ?? event.id);
|
|
930
|
+
const command = String(props.command ?? '');
|
|
931
|
+
state.shellCommands.set(callID, command);
|
|
932
|
+
emit({
|
|
933
|
+
type: 'tool-call',
|
|
934
|
+
toolCallId: callID,
|
|
935
|
+
toolName: 'bash',
|
|
936
|
+
nativeName: 'bash',
|
|
937
|
+
input: JSON.stringify({ command }),
|
|
938
|
+
providerExecuted: true,
|
|
939
|
+
});
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (type === 'session.next.shell.ended') {
|
|
943
|
+
const callID = String(props.callID ?? event.id);
|
|
944
|
+
emit({
|
|
945
|
+
type: 'tool-result',
|
|
946
|
+
toolCallId: callID,
|
|
947
|
+
toolName: 'bash',
|
|
948
|
+
result: {
|
|
949
|
+
command: state.shellCommands.get(callID) ?? '',
|
|
950
|
+
output: String(props.output ?? ''),
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (type === 'session.next.tool.input.delta') {
|
|
956
|
+
const callID = String(props.callID ?? event.id);
|
|
957
|
+
state.toolInputs.set(
|
|
958
|
+
callID,
|
|
959
|
+
`${state.toolInputs.get(callID) ?? ''}${String(props.delta ?? '')}`,
|
|
960
|
+
);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (type === 'session.next.tool.input.ended') {
|
|
964
|
+
state.toolInputs.set(
|
|
965
|
+
String(props.callID ?? event.id),
|
|
966
|
+
String(props.text ?? ''),
|
|
967
|
+
);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (type === 'session.next.tool.called') {
|
|
971
|
+
const callID = String(props.callID ?? event.id);
|
|
972
|
+
const rawToolName = String(props.tool ?? 'unknown');
|
|
973
|
+
const toolName = toWireToolName(rawToolName);
|
|
974
|
+
state.toolNames.set(callID, { rawToolName, toolName });
|
|
975
|
+
const hostToolName = getHostToolName(toolName, props.tool);
|
|
976
|
+
if (hostToolName) {
|
|
977
|
+
authorizeHostToolCall({
|
|
978
|
+
callID,
|
|
979
|
+
toolName: hostToolName,
|
|
980
|
+
input: props.input ?? parseToolInput(state, props),
|
|
981
|
+
state,
|
|
982
|
+
});
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
emit({
|
|
986
|
+
type: 'tool-call',
|
|
987
|
+
toolCallId: callID,
|
|
988
|
+
toolName,
|
|
989
|
+
...nativeNameField({ nativeName: rawToolName, toolName }),
|
|
990
|
+
input: JSON.stringify(props.input ?? parseToolInput(state, props)),
|
|
991
|
+
providerExecuted: true,
|
|
992
|
+
...(props.provider?.metadata
|
|
993
|
+
? { providerMetadata: props.provider.metadata }
|
|
994
|
+
: {}),
|
|
995
|
+
});
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (
|
|
999
|
+
type === 'session.next.tool.success' ||
|
|
1000
|
+
type === 'session.next.tool.failed'
|
|
1001
|
+
) {
|
|
1002
|
+
const callID = String(props.callID ?? event.id);
|
|
1003
|
+
const cachedTool = state.toolNames.get(callID);
|
|
1004
|
+
const rawToolName =
|
|
1005
|
+
cachedTool?.rawToolName ??
|
|
1006
|
+
String((props as { tool?: unknown }).tool ?? '');
|
|
1007
|
+
const toolName =
|
|
1008
|
+
cachedTool?.toolName ?? toWireToolName(rawToolName || 'unknown');
|
|
1009
|
+
if (getHostToolName(toolName, rawToolName)) return;
|
|
1010
|
+
emit({
|
|
1011
|
+
type: 'tool-result',
|
|
1012
|
+
toolCallId: callID,
|
|
1013
|
+
toolName,
|
|
1014
|
+
result:
|
|
1015
|
+
props.result ??
|
|
1016
|
+
props.structured ??
|
|
1017
|
+
('content' in props ? props.content : null) ??
|
|
1018
|
+
null,
|
|
1019
|
+
...(type === 'session.next.tool.failed' ? { isError: true } : {}),
|
|
1020
|
+
});
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (type === 'session.next.step.ended') {
|
|
1024
|
+
closeLegacyOpenParts({ state, emit });
|
|
1025
|
+
state.turnUsage = mapUsage(props.tokens);
|
|
1026
|
+
emit({
|
|
1027
|
+
type: 'finish-step',
|
|
1028
|
+
finishReason: {
|
|
1029
|
+
unified: mapFinishReason(String(props.finish ?? 'stop')),
|
|
1030
|
+
raw: String(props.finish ?? 'stop'),
|
|
1031
|
+
},
|
|
1032
|
+
usage: state.turnUsage,
|
|
1033
|
+
...(typeof props.cost === 'number'
|
|
1034
|
+
? { harnessMetadata: { opencode: { cost: props.cost } } }
|
|
1035
|
+
: {}),
|
|
1036
|
+
});
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (type === 'session.next.compaction.ended') {
|
|
1040
|
+
emit({
|
|
1041
|
+
type: 'compaction',
|
|
1042
|
+
trigger: props.reason === 'auto' ? 'auto' : 'manual',
|
|
1043
|
+
summary: String(props.text ?? ''),
|
|
1044
|
+
harnessMetadata: {
|
|
1045
|
+
opencode: {
|
|
1046
|
+
recent: String(props.recent ?? ''),
|
|
1047
|
+
},
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (type === 'file.edited') {
|
|
1053
|
+
emit({
|
|
1054
|
+
type: 'file-change',
|
|
1055
|
+
event: 'modify',
|
|
1056
|
+
path: stripWorkDir(String(props.file ?? '')),
|
|
1057
|
+
});
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (type === 'session.error' || type === 'session.next.step.failed') {
|
|
1061
|
+
emit({ type: 'error', error: formatError(props.error ?? event) });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (type === 'permission.v2.asked') {
|
|
1065
|
+
await handlePermissionV2({
|
|
1066
|
+
client,
|
|
1067
|
+
sessionId,
|
|
1068
|
+
permissionMode,
|
|
1069
|
+
builtinToolFiltering,
|
|
1070
|
+
turn,
|
|
1071
|
+
emit,
|
|
1072
|
+
event,
|
|
1073
|
+
});
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (type === 'permission.asked') {
|
|
1077
|
+
await handlePermission({
|
|
1078
|
+
client,
|
|
1079
|
+
sessionId,
|
|
1080
|
+
permissionMode,
|
|
1081
|
+
builtinToolFiltering,
|
|
1082
|
+
turn,
|
|
1083
|
+
emit,
|
|
1084
|
+
event,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function legacyPartId({
|
|
1090
|
+
value,
|
|
1091
|
+
fallback,
|
|
1092
|
+
}: {
|
|
1093
|
+
value: Record<string, unknown>;
|
|
1094
|
+
fallback: string;
|
|
1095
|
+
}): string {
|
|
1096
|
+
return stringValue(value.partID) ?? stringValue(value.id) ?? fallback;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function startLegacyPart({
|
|
1100
|
+
ids,
|
|
1101
|
+
id,
|
|
1102
|
+
emit,
|
|
1103
|
+
type,
|
|
1104
|
+
}: {
|
|
1105
|
+
ids: Set<string>;
|
|
1106
|
+
id: string;
|
|
1107
|
+
emit: Emit;
|
|
1108
|
+
type: 'text' | 'reasoning';
|
|
1109
|
+
}): void {
|
|
1110
|
+
if (ids.has(id)) return;
|
|
1111
|
+
ids.add(id);
|
|
1112
|
+
emit({ type: `${type}-start`, id });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function emitLegacyTextPartUpdate({
|
|
1116
|
+
part,
|
|
1117
|
+
state,
|
|
1118
|
+
emit,
|
|
1119
|
+
}: {
|
|
1120
|
+
part: unknown;
|
|
1121
|
+
state: TranslationState;
|
|
1122
|
+
emit: Emit;
|
|
1123
|
+
}): boolean {
|
|
1124
|
+
if (!isRecord(part)) return false;
|
|
1125
|
+
if (part.type !== 'text' && part.type !== 'reasoning') return false;
|
|
1126
|
+
const id = stringValue(part.id);
|
|
1127
|
+
if (!id) return true;
|
|
1128
|
+
|
|
1129
|
+
const messageID = stringValue(part.messageID);
|
|
1130
|
+
if (messageID && state.messageRoles.get(messageID) === 'user') return true;
|
|
1131
|
+
|
|
1132
|
+
const isReasoning = part.type === 'reasoning';
|
|
1133
|
+
const ids = isReasoning
|
|
1134
|
+
? state.legacyReasoningPartIds
|
|
1135
|
+
: state.legacyTextPartIds;
|
|
1136
|
+
const deltaMap = isReasoning ? state.reasoningDeltas : state.textDeltas;
|
|
1137
|
+
const deltaType = isReasoning ? 'reasoning-delta' : 'text-delta';
|
|
1138
|
+
const text = typeof part.text === 'string' ? part.text : undefined;
|
|
1139
|
+
|
|
1140
|
+
startLegacyPart({
|
|
1141
|
+
ids,
|
|
1142
|
+
id,
|
|
1143
|
+
emit,
|
|
1144
|
+
type: isReasoning ? 'reasoning' : 'text',
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
if (text !== undefined) {
|
|
1148
|
+
emitMissingFinalDelta({
|
|
1149
|
+
id,
|
|
1150
|
+
fullText: text,
|
|
1151
|
+
emittedText: deltaMap.get(id) ?? '',
|
|
1152
|
+
emit,
|
|
1153
|
+
type: deltaType,
|
|
1154
|
+
});
|
|
1155
|
+
deltaMap.set(id, text);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (legacyPartEnded(part)) {
|
|
1159
|
+
ids.delete(id);
|
|
1160
|
+
deltaMap.delete(id);
|
|
1161
|
+
emit({ type: isReasoning ? 'reasoning-end' : 'text-end', id });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function legacyPartEnded(part: Record<string, unknown>): boolean {
|
|
1168
|
+
return isRecord(part.time) && part.time.end != null;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function closeLegacyOpenParts({
|
|
1172
|
+
state,
|
|
1173
|
+
emit,
|
|
1174
|
+
}: {
|
|
1175
|
+
state: TranslationState;
|
|
1176
|
+
emit: Emit;
|
|
1177
|
+
}): void {
|
|
1178
|
+
for (const id of state.legacyReasoningPartIds) {
|
|
1179
|
+
emit({ type: 'reasoning-end', id });
|
|
1180
|
+
state.reasoningDeltas.delete(id);
|
|
1181
|
+
}
|
|
1182
|
+
state.legacyReasoningPartIds.clear();
|
|
1183
|
+
for (const id of state.legacyTextPartIds) {
|
|
1184
|
+
emit({ type: 'text-end', id });
|
|
1185
|
+
state.textDeltas.delete(id);
|
|
1186
|
+
}
|
|
1187
|
+
state.legacyTextPartIds.clear();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function emitLegacyToolPart({
|
|
1191
|
+
part,
|
|
1192
|
+
state,
|
|
1193
|
+
emit,
|
|
1194
|
+
}: {
|
|
1195
|
+
part: unknown;
|
|
1196
|
+
state: TranslationState;
|
|
1197
|
+
emit: Emit;
|
|
1198
|
+
}): void {
|
|
1199
|
+
if (!part || typeof part !== 'object') return;
|
|
1200
|
+
const toolPart = part as Record<string, any>;
|
|
1201
|
+
if (toolPart.type !== 'tool') return;
|
|
1202
|
+
const status = legacyToolPartStatus(toolPart);
|
|
1203
|
+
if (status !== 'running' && status !== 'completed' && status !== 'error') {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (
|
|
1207
|
+
typeof toolPart.tool !== 'string' ||
|
|
1208
|
+
typeof toolPart.callID !== 'string'
|
|
1209
|
+
) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const callID = toolPart.callID;
|
|
1213
|
+
const rawToolName = toolPart.tool;
|
|
1214
|
+
const toolName = toWireToolName(rawToolName);
|
|
1215
|
+
state.toolNames.set(callID, { rawToolName, toolName });
|
|
1216
|
+
const hostToolName = getHostToolName(toolName, rawToolName);
|
|
1217
|
+
if (hostToolName) {
|
|
1218
|
+
if (status === 'running') {
|
|
1219
|
+
authorizeHostToolCall({
|
|
1220
|
+
callID,
|
|
1221
|
+
toolName: hostToolName,
|
|
1222
|
+
input: legacyToolPartInput(toolPart),
|
|
1223
|
+
state,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (!state.toolCallsEmitted.has(callID)) {
|
|
1229
|
+
state.toolCallsEmitted.add(callID);
|
|
1230
|
+
emit({
|
|
1231
|
+
type: 'tool-call',
|
|
1232
|
+
toolCallId: callID,
|
|
1233
|
+
toolName,
|
|
1234
|
+
...nativeNameField({ nativeName: rawToolName, toolName }),
|
|
1235
|
+
input: JSON.stringify(legacyToolPartInput(toolPart)),
|
|
1236
|
+
providerExecuted: true,
|
|
1237
|
+
...(toolPart.provider?.metadata
|
|
1238
|
+
? { providerMetadata: toolPart.provider.metadata }
|
|
1239
|
+
: {}),
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
if (
|
|
1243
|
+
(status === 'completed' || status === 'error') &&
|
|
1244
|
+
!state.toolResultsEmitted.has(callID)
|
|
1245
|
+
) {
|
|
1246
|
+
state.toolResultsEmitted.add(callID);
|
|
1247
|
+
emit({
|
|
1248
|
+
type: 'tool-result',
|
|
1249
|
+
toolCallId: callID,
|
|
1250
|
+
toolName,
|
|
1251
|
+
result: legacyToolPartOutput(toolPart),
|
|
1252
|
+
...(status === 'error' ? { isError: true } : {}),
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function legacyToolPartStatus(part: Record<string, any>): string | undefined {
|
|
1258
|
+
return typeof part.state === 'string'
|
|
1259
|
+
? part.state
|
|
1260
|
+
: typeof part.state === 'object' && part.state !== null
|
|
1261
|
+
? String(part.state.status ?? '')
|
|
1262
|
+
: undefined;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function legacyToolPartInput(
|
|
1266
|
+
part: Record<string, any>,
|
|
1267
|
+
): Record<string, unknown> {
|
|
1268
|
+
const state =
|
|
1269
|
+
typeof part.state === 'object' && part.state !== null
|
|
1270
|
+
? (part.state as Record<string, any>)
|
|
1271
|
+
: undefined;
|
|
1272
|
+
return {
|
|
1273
|
+
...(isRecord(part.metadata) ? part.metadata : {}),
|
|
1274
|
+
...(isRecord(state?.metadata) ? state.metadata : {}),
|
|
1275
|
+
...(isRecord(state?.input) ? state.input : {}),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function legacyToolPartOutput(part: Record<string, any>): unknown {
|
|
1280
|
+
const state =
|
|
1281
|
+
typeof part.state === 'object' && part.state !== null
|
|
1282
|
+
? (part.state as Record<string, any>)
|
|
1283
|
+
: undefined;
|
|
1284
|
+
if (state?.status === 'error') {
|
|
1285
|
+
return state.error ?? part.error ?? state.result ?? 'tool failed';
|
|
1286
|
+
}
|
|
1287
|
+
return (
|
|
1288
|
+
state?.output ??
|
|
1289
|
+
state?.result ??
|
|
1290
|
+
state?.structured ??
|
|
1291
|
+
state?.content ??
|
|
1292
|
+
null
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1297
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function parseToolInput(
|
|
1301
|
+
state: TranslationState,
|
|
1302
|
+
props: Record<string, any>,
|
|
1303
|
+
): unknown {
|
|
1304
|
+
const text = state.toolInputs.get(String(props.callID ?? ''));
|
|
1305
|
+
if (!text) return {};
|
|
1306
|
+
try {
|
|
1307
|
+
return JSON.parse(text);
|
|
1308
|
+
} catch {
|
|
1309
|
+
return { input: text };
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function handlePermissionV2({
|
|
1314
|
+
client,
|
|
1315
|
+
sessionId,
|
|
1316
|
+
permissionMode,
|
|
1317
|
+
builtinToolFiltering,
|
|
1318
|
+
turn,
|
|
1319
|
+
emit,
|
|
1320
|
+
event,
|
|
1321
|
+
}: {
|
|
1322
|
+
client: OpenCodeClient;
|
|
1323
|
+
sessionId: string;
|
|
1324
|
+
permissionMode: StartMessage['permissionMode'];
|
|
1325
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
1326
|
+
turn: BridgeTurn;
|
|
1327
|
+
emit: Emit;
|
|
1328
|
+
event: OpenCodeEvent;
|
|
1329
|
+
}): Promise<void> {
|
|
1330
|
+
const props = event.properties ?? {};
|
|
1331
|
+
const requestID = String(props.id ?? '');
|
|
1332
|
+
if (!requestID) return;
|
|
1333
|
+
const reply = await selectPermissionReply({
|
|
1334
|
+
action: String(props.action ?? ''),
|
|
1335
|
+
resources: Array.isArray(props.resources)
|
|
1336
|
+
? props.resources.map(String)
|
|
1337
|
+
: [],
|
|
1338
|
+
requestID,
|
|
1339
|
+
toolCallId:
|
|
1340
|
+
typeof props.source === 'object' &&
|
|
1341
|
+
props.source !== null &&
|
|
1342
|
+
'callID' in props.source
|
|
1343
|
+
? String((props.source as { callID?: unknown }).callID)
|
|
1344
|
+
: requestID,
|
|
1345
|
+
permissionMode,
|
|
1346
|
+
builtinToolFiltering,
|
|
1347
|
+
turn,
|
|
1348
|
+
emit,
|
|
1349
|
+
});
|
|
1350
|
+
await client.v2.session.permission.reply({
|
|
1351
|
+
sessionID: sessionId,
|
|
1352
|
+
requestID,
|
|
1353
|
+
reply: reply.reply,
|
|
1354
|
+
...(reply.message ? { message: reply.message } : {}),
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async function handlePermission({
|
|
1359
|
+
client,
|
|
1360
|
+
sessionId,
|
|
1361
|
+
permissionMode,
|
|
1362
|
+
builtinToolFiltering,
|
|
1363
|
+
turn,
|
|
1364
|
+
emit,
|
|
1365
|
+
event,
|
|
1366
|
+
}: {
|
|
1367
|
+
client: OpenCodeClient;
|
|
1368
|
+
sessionId: string;
|
|
1369
|
+
permissionMode: StartMessage['permissionMode'];
|
|
1370
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
1371
|
+
turn: BridgeTurn;
|
|
1372
|
+
emit: Emit;
|
|
1373
|
+
event: OpenCodeEvent;
|
|
1374
|
+
}): Promise<void> {
|
|
1375
|
+
const props = event.properties ?? {};
|
|
1376
|
+
const requestID = String(props.id ?? '');
|
|
1377
|
+
if (!requestID) return;
|
|
1378
|
+
const reply = await selectPermissionReply({
|
|
1379
|
+
action: String(props.permission ?? ''),
|
|
1380
|
+
resources: Array.isArray(props.patterns) ? props.patterns.map(String) : [],
|
|
1381
|
+
requestID,
|
|
1382
|
+
toolCallId:
|
|
1383
|
+
typeof props.tool === 'object' &&
|
|
1384
|
+
props.tool !== null &&
|
|
1385
|
+
'callID' in props.tool
|
|
1386
|
+
? String((props.tool as { callID?: unknown }).callID)
|
|
1387
|
+
: requestID,
|
|
1388
|
+
permissionMode,
|
|
1389
|
+
builtinToolFiltering,
|
|
1390
|
+
turn,
|
|
1391
|
+
emit,
|
|
1392
|
+
});
|
|
1393
|
+
await client.permission.reply({
|
|
1394
|
+
requestID,
|
|
1395
|
+
directory: workdir,
|
|
1396
|
+
reply: reply.reply,
|
|
1397
|
+
...(reply.message ? { message: reply.message } : {}),
|
|
1398
|
+
});
|
|
1399
|
+
void sessionId;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async function selectPermissionReply({
|
|
1403
|
+
action,
|
|
1404
|
+
resources,
|
|
1405
|
+
requestID,
|
|
1406
|
+
toolCallId,
|
|
1407
|
+
permissionMode,
|
|
1408
|
+
builtinToolFiltering,
|
|
1409
|
+
turn,
|
|
1410
|
+
emit,
|
|
1411
|
+
}: {
|
|
1412
|
+
action: string;
|
|
1413
|
+
resources: string[];
|
|
1414
|
+
requestID: string;
|
|
1415
|
+
toolCallId: string;
|
|
1416
|
+
permissionMode: StartMessage['permissionMode'];
|
|
1417
|
+
builtinToolFiltering: StartMessage['builtinToolFiltering'];
|
|
1418
|
+
turn: BridgeTurn;
|
|
1419
|
+
emit: Emit;
|
|
1420
|
+
}): Promise<{ reply: 'once' | 'always' | 'reject'; message?: string }> {
|
|
1421
|
+
const toolName = toPermissionToolName(action);
|
|
1422
|
+
if (resources.some(resource => isExternalPath(resource))) {
|
|
1423
|
+
return { reply: 'reject', message: 'External directory access rejected.' };
|
|
1424
|
+
}
|
|
1425
|
+
if (
|
|
1426
|
+
isBuiltinToolInactive({ toolName, toolFiltering: builtinToolFiltering })
|
|
1427
|
+
) {
|
|
1428
|
+
emit({
|
|
1429
|
+
type: 'tool-approval-request',
|
|
1430
|
+
approvalId: requestID,
|
|
1431
|
+
toolCallId,
|
|
1432
|
+
});
|
|
1433
|
+
const decision = await turn.requestToolApproval(requestID);
|
|
1434
|
+
return decision.approved
|
|
1435
|
+
? { reply: 'once' }
|
|
1436
|
+
: {
|
|
1437
|
+
reply: 'reject',
|
|
1438
|
+
...(decision.reason ? { message: decision.reason } : {}),
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
if (!permissionMode || permissionMode === 'allow-all') {
|
|
1442
|
+
return { reply: 'always' };
|
|
1443
|
+
}
|
|
1444
|
+
const kind = TOOL_KIND[toolName] ?? 'bash';
|
|
1445
|
+
const allowed =
|
|
1446
|
+
permissionMode === 'allow-edits'
|
|
1447
|
+
? kind === 'readonly' || kind === 'edit'
|
|
1448
|
+
: kind === 'readonly';
|
|
1449
|
+
if (allowed) return { reply: 'always' };
|
|
1450
|
+
|
|
1451
|
+
emit({
|
|
1452
|
+
type: 'tool-approval-request',
|
|
1453
|
+
approvalId: requestID,
|
|
1454
|
+
toolCallId,
|
|
1455
|
+
});
|
|
1456
|
+
const decision = await turn.requestToolApproval(requestID);
|
|
1457
|
+
return decision.approved
|
|
1458
|
+
? { reply: 'once' }
|
|
1459
|
+
: {
|
|
1460
|
+
reply: 'reject',
|
|
1461
|
+
...(decision.reason ? { message: decision.reason } : {}),
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function toPermissionToolName(action: string): string {
|
|
1466
|
+
const normalized = action.toLowerCase();
|
|
1467
|
+
if (normalized.includes('bash') || normalized.includes('shell'))
|
|
1468
|
+
return 'bash';
|
|
1469
|
+
if (normalized.includes('edit')) return 'edit';
|
|
1470
|
+
if (normalized.includes('write')) return 'write';
|
|
1471
|
+
if (normalized.includes('webfetch')) return 'webfetch';
|
|
1472
|
+
if (normalized.includes('task') || normalized.includes('agent'))
|
|
1473
|
+
return 'agent';
|
|
1474
|
+
if (normalized.includes('list')) return 'ls';
|
|
1475
|
+
if (normalized.includes('grep')) return 'grep';
|
|
1476
|
+
if (normalized.includes('glob')) return 'glob';
|
|
1477
|
+
if (normalized.includes('read')) return 'read';
|
|
1478
|
+
return toWireToolName(normalized);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function resolveInactiveBuiltinToolNames(
|
|
1482
|
+
start: StartMessage,
|
|
1483
|
+
): ReadonlyArray<string> {
|
|
1484
|
+
const toolFiltering = start.builtinToolFiltering;
|
|
1485
|
+
if (toolFiltering == null) return [];
|
|
1486
|
+
return toolFiltering.mode === 'allow'
|
|
1487
|
+
? Object.keys(PUBLIC_TO_NATIVE).filter(
|
|
1488
|
+
name => !toolFiltering.toolNames.includes(name),
|
|
1489
|
+
)
|
|
1490
|
+
: toolFiltering.toolNames;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function isBuiltinToolInactive(input: {
|
|
1494
|
+
toolName: string;
|
|
1495
|
+
toolFiltering: StartMessage['builtinToolFiltering'];
|
|
1496
|
+
}): boolean {
|
|
1497
|
+
if (input.toolFiltering == null) return false;
|
|
1498
|
+
return input.toolFiltering.mode === 'allow'
|
|
1499
|
+
? !input.toolFiltering.toolNames.includes(input.toolName)
|
|
1500
|
+
: input.toolFiltering.toolNames.includes(input.toolName);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function isExternalPath(resource: string): boolean {
|
|
1504
|
+
if (!path.isAbsolute(resource)) return false;
|
|
1505
|
+
const normalized = path.resolve(resource);
|
|
1506
|
+
return (
|
|
1507
|
+
!isPathInsideOrEqual(normalized, workdir) &&
|
|
1508
|
+
(!skillsDir || !isPathInsideOrEqual(normalized, skillsDir))
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function isPathInsideOrEqual(file: string, root: string): boolean {
|
|
1513
|
+
const normalizedRoot = path.resolve(root);
|
|
1514
|
+
return file === normalizedRoot || file.startsWith(`${normalizedRoot}/`);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function toWireToolName(nativeName: string): string {
|
|
1518
|
+
return (
|
|
1519
|
+
NATIVE_TO_COMMON[nativeName] ?? OPENCODE_TO_WIRE[nativeName] ?? nativeName
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function nativeNameField({
|
|
1524
|
+
nativeName,
|
|
1525
|
+
toolName,
|
|
1526
|
+
}: {
|
|
1527
|
+
nativeName: string;
|
|
1528
|
+
toolName: string;
|
|
1529
|
+
}): { nativeName?: string } {
|
|
1530
|
+
if (!nativeName || nativeName === toolName || toolName === 'agent') return {};
|
|
1531
|
+
return { nativeName };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function getHostToolName(
|
|
1535
|
+
toolName: string,
|
|
1536
|
+
rawToolName: unknown,
|
|
1537
|
+
): string | undefined {
|
|
1538
|
+
if (runtime.toolNames.has(toolName)) return toolName;
|
|
1539
|
+
if (typeof rawToolName === 'string' && runtime.toolNames.has(rawToolName)) {
|
|
1540
|
+
return rawToolName;
|
|
1541
|
+
}
|
|
1542
|
+
if (
|
|
1543
|
+
typeof rawToolName === 'string' &&
|
|
1544
|
+
rawToolName.startsWith('harness-tools_') &&
|
|
1545
|
+
runtime.toolNames.has(rawToolName.slice('harness-tools_'.length))
|
|
1546
|
+
) {
|
|
1547
|
+
return rawToolName.slice('harness-tools_'.length);
|
|
1548
|
+
}
|
|
1549
|
+
return undefined;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function authorizeHostToolCall({
|
|
1553
|
+
callID,
|
|
1554
|
+
toolName,
|
|
1555
|
+
input,
|
|
1556
|
+
state,
|
|
1557
|
+
}: {
|
|
1558
|
+
callID: string;
|
|
1559
|
+
toolName: string;
|
|
1560
|
+
input: unknown;
|
|
1561
|
+
state: TranslationState;
|
|
1562
|
+
}): void {
|
|
1563
|
+
if (state.hostToolCallsAuthorized.has(callID)) return;
|
|
1564
|
+
state.hostToolCallsAuthorized.add(callID);
|
|
1565
|
+
runtime.relay?.authorizeToolCall({ toolName, input });
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async function emitContextFallback({
|
|
1569
|
+
client,
|
|
1570
|
+
sessionId,
|
|
1571
|
+
emit,
|
|
1572
|
+
emitContent,
|
|
1573
|
+
}: {
|
|
1574
|
+
client: OpenCodeClient;
|
|
1575
|
+
sessionId: string;
|
|
1576
|
+
emit: Emit;
|
|
1577
|
+
emitContent: boolean;
|
|
1578
|
+
}): Promise<boolean> {
|
|
1579
|
+
const assistant = await latestAssistantSnapshot({ client, sessionId });
|
|
1580
|
+
if (!assistant) return false;
|
|
1581
|
+
if (emitContent && Array.isArray(assistant.contentParts)) {
|
|
1582
|
+
for (const part of assistant.contentParts) {
|
|
1583
|
+
emitAssistantContentPart(part, emit);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
const rawFinish =
|
|
1587
|
+
typeof assistant.finish === 'string'
|
|
1588
|
+
? assistant.finish
|
|
1589
|
+
: assistant.error
|
|
1590
|
+
? 'error'
|
|
1591
|
+
: 'stop';
|
|
1592
|
+
emit({
|
|
1593
|
+
type: 'finish-step',
|
|
1594
|
+
finishReason: {
|
|
1595
|
+
unified: mapFinishReason(rawFinish),
|
|
1596
|
+
raw: rawFinish,
|
|
1597
|
+
},
|
|
1598
|
+
usage: mapUsage(assistant.tokens),
|
|
1599
|
+
...(typeof assistant.cost === 'number'
|
|
1600
|
+
? {
|
|
1601
|
+
harnessMetadata: {
|
|
1602
|
+
opencode: { cost: assistant.cost, fallback: true },
|
|
1603
|
+
},
|
|
1604
|
+
}
|
|
1605
|
+
: { harnessMetadata: { opencode: { fallback: true } } }),
|
|
1606
|
+
});
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
async function readSessionTokens({
|
|
1611
|
+
client,
|
|
1612
|
+
sessionId,
|
|
1613
|
+
}: {
|
|
1614
|
+
client: OpenCodeClient;
|
|
1615
|
+
sessionId: string;
|
|
1616
|
+
}): Promise<OpenCodeTokenUsage | undefined> {
|
|
1617
|
+
const session = await legacySessionGet({ client, sessionId });
|
|
1618
|
+
if (session.error) return undefined;
|
|
1619
|
+
return extractSessionTokens(session.data);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
type AssistantSnapshot = {
|
|
1623
|
+
contentParts?: unknown[];
|
|
1624
|
+
metadata?: unknown;
|
|
1625
|
+
model?: unknown;
|
|
1626
|
+
modelID?: unknown;
|
|
1627
|
+
providerID?: unknown;
|
|
1628
|
+
tokens?: unknown;
|
|
1629
|
+
finish?: unknown;
|
|
1630
|
+
cost?: unknown;
|
|
1631
|
+
error?: unknown;
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
async function latestAssistantSnapshot({
|
|
1635
|
+
client,
|
|
1636
|
+
sessionId,
|
|
1637
|
+
}: {
|
|
1638
|
+
client: OpenCodeClient;
|
|
1639
|
+
sessionId: string;
|
|
1640
|
+
}): Promise<AssistantSnapshot | undefined> {
|
|
1641
|
+
const legacy = await (client as any).session
|
|
1642
|
+
.messages({ sessionID: sessionId, limit: 20 })
|
|
1643
|
+
.catch(() => undefined);
|
|
1644
|
+
const legacyAssistant = latestLegacyAssistantMessage(legacy?.data);
|
|
1645
|
+
if (legacyAssistant) return legacyAssistant;
|
|
1646
|
+
|
|
1647
|
+
const context = await client.v2.session
|
|
1648
|
+
.context({ sessionID: sessionId })
|
|
1649
|
+
.catch(() => undefined);
|
|
1650
|
+
if (!context || context.error) return undefined;
|
|
1651
|
+
return latestV2AssistantMessage(context.data);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function latestLegacyAssistantMessage(
|
|
1655
|
+
data: unknown,
|
|
1656
|
+
): AssistantSnapshot | undefined {
|
|
1657
|
+
const messages = Array.isArray(data) ? data : [];
|
|
1658
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1659
|
+
const item = messages[i];
|
|
1660
|
+
if (!item || typeof item !== 'object') continue;
|
|
1661
|
+
const record = item as { info?: unknown; parts?: unknown };
|
|
1662
|
+
const info = record.info;
|
|
1663
|
+
if (
|
|
1664
|
+
info &&
|
|
1665
|
+
typeof info === 'object' &&
|
|
1666
|
+
(info as { role?: unknown }).role === 'assistant'
|
|
1667
|
+
) {
|
|
1668
|
+
return {
|
|
1669
|
+
...(info as Record<string, unknown>),
|
|
1670
|
+
contentParts: Array.isArray(record.parts) ? record.parts : undefined,
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return undefined;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function latestV2AssistantMessage(
|
|
1678
|
+
data: unknown,
|
|
1679
|
+
): AssistantSnapshot | undefined {
|
|
1680
|
+
const messages =
|
|
1681
|
+
data &&
|
|
1682
|
+
typeof data === 'object' &&
|
|
1683
|
+
Array.isArray((data as { data?: unknown }).data)
|
|
1684
|
+
? (data as { data: unknown[] }).data
|
|
1685
|
+
: Array.isArray(data)
|
|
1686
|
+
? data
|
|
1687
|
+
: [];
|
|
1688
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1689
|
+
const message = messages[i];
|
|
1690
|
+
if (
|
|
1691
|
+
message &&
|
|
1692
|
+
typeof message === 'object' &&
|
|
1693
|
+
(message as { type?: unknown }).type === 'assistant'
|
|
1694
|
+
) {
|
|
1695
|
+
const record = message as Record<string, unknown>;
|
|
1696
|
+
return {
|
|
1697
|
+
...record,
|
|
1698
|
+
contentParts: Array.isArray(record.content)
|
|
1699
|
+
? record.content
|
|
1700
|
+
: undefined,
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return undefined;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function emitAssistantContentPart(part: unknown, emit: Emit): void {
|
|
1708
|
+
if (!part || typeof part !== 'object') return;
|
|
1709
|
+
const value = part as { type?: unknown; id?: unknown; text?: unknown };
|
|
1710
|
+
if (value.type !== 'text' && value.type !== 'reasoning') return;
|
|
1711
|
+
const id =
|
|
1712
|
+
typeof value.id === 'string' && value.id.length > 0
|
|
1713
|
+
? value.id
|
|
1714
|
+
: `${value.type}-${randomUUID()}`;
|
|
1715
|
+
const text = typeof value.text === 'string' ? value.text : '';
|
|
1716
|
+
if (value.type === 'text') {
|
|
1717
|
+
emit({ type: 'text-start', id });
|
|
1718
|
+
if (text) emit({ type: 'text-delta', id, delta: text });
|
|
1719
|
+
emit({ type: 'text-end', id });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
emit({ type: 'reasoning-start', id });
|
|
1723
|
+
if (text) emit({ type: 'reasoning-delta', id, delta: text });
|
|
1724
|
+
emit({ type: 'reasoning-end', id });
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function mapFinishReason(
|
|
1728
|
+
reason: string,
|
|
1729
|
+
): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' {
|
|
1730
|
+
const normalized = reason.toLowerCase();
|
|
1731
|
+
if (normalized.includes('length')) return 'length';
|
|
1732
|
+
if (normalized.includes('filter')) return 'content-filter';
|
|
1733
|
+
if (normalized.includes('tool')) return 'tool-calls';
|
|
1734
|
+
if (normalized.includes('error') || normalized.includes('fail'))
|
|
1735
|
+
return 'error';
|
|
1736
|
+
if (normalized === 'stop' || normalized === 'end') return 'stop';
|
|
1737
|
+
return 'other';
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
async function startToolRelay({
|
|
1741
|
+
allowedScriptPaths,
|
|
1742
|
+
tools,
|
|
1743
|
+
emit,
|
|
1744
|
+
requestToolResult,
|
|
1745
|
+
}: {
|
|
1746
|
+
allowedScriptPaths: ReadonlyArray<string>;
|
|
1747
|
+
tools: ReadonlyArray<{ name: string }>;
|
|
1748
|
+
emit: Emit;
|
|
1749
|
+
requestToolResult: (
|
|
1750
|
+
toolCallId: string,
|
|
1751
|
+
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
1752
|
+
}): Promise<ToolRelay> {
|
|
1753
|
+
const toolNames = new Set(tools.map(t => t.name));
|
|
1754
|
+
const allowedScriptPathSet = new Set(allowedScriptPaths);
|
|
1755
|
+
const authorizer = new ToolRelayAuthorizer();
|
|
1756
|
+
const server = createServer(async (req, res) => {
|
|
1757
|
+
try {
|
|
1758
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
1759
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1760
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
const chunks: Buffer[] = [];
|
|
1764
|
+
for await (const chunk of req) {
|
|
1765
|
+
chunks.push(chunk as Buffer);
|
|
1766
|
+
}
|
|
1767
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
1768
|
+
const { requestId, toolName, input } = JSON.parse(body) as {
|
|
1769
|
+
requestId: string;
|
|
1770
|
+
toolName: string;
|
|
1771
|
+
input: unknown;
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
if (!toolNames.has(toolName)) {
|
|
1775
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1776
|
+
res.end(
|
|
1777
|
+
JSON.stringify({ error: `Tool "${toolName}" is not available` }),
|
|
1778
|
+
);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const authorized =
|
|
1782
|
+
authorizer.consumeToolCall({ toolName, input }) ||
|
|
1783
|
+
(await isToolRelayRequestFromAllowedProcess({
|
|
1784
|
+
socket: req.socket,
|
|
1785
|
+
allowedScriptPaths: allowedScriptPathSet,
|
|
1786
|
+
}));
|
|
1787
|
+
if (!authorized) {
|
|
1788
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1789
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
emit({
|
|
1794
|
+
type: 'tool-call',
|
|
1795
|
+
toolCallId: requestId,
|
|
1796
|
+
toolName,
|
|
1797
|
+
input: JSON.stringify(input ?? {}),
|
|
1798
|
+
providerExecuted: false,
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
const { output, isError } = await requestToolResult(requestId);
|
|
1802
|
+
emit({
|
|
1803
|
+
type: 'tool-result',
|
|
1804
|
+
toolCallId: requestId,
|
|
1805
|
+
toolName,
|
|
1806
|
+
result: output ?? null,
|
|
1807
|
+
isError: !!isError,
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1811
|
+
res.end(JSON.stringify({ result: output }));
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1814
|
+
res.end(
|
|
1815
|
+
JSON.stringify({
|
|
1816
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1817
|
+
}),
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
await new Promise<void>(resolve =>
|
|
1823
|
+
server.listen(0, '127.0.0.1', () => resolve()),
|
|
1824
|
+
);
|
|
1825
|
+
const address = server.address();
|
|
1826
|
+
if (!address || typeof address === 'string') {
|
|
1827
|
+
throw new Error('tool relay did not expose a numeric port');
|
|
1828
|
+
}
|
|
1829
|
+
return {
|
|
1830
|
+
port: address.port,
|
|
1831
|
+
close: () => closeServer(server),
|
|
1832
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function closeServer(server: Server): void {
|
|
1837
|
+
try {
|
|
1838
|
+
server.close();
|
|
1839
|
+
} catch {}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function createDeferred<T>(): {
|
|
1843
|
+
promise: Promise<T>;
|
|
1844
|
+
resolve(value: T | PromiseLike<T>): void;
|
|
1845
|
+
} {
|
|
1846
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
1847
|
+
const promise = new Promise<T>(res => {
|
|
1848
|
+
resolve = res;
|
|
1849
|
+
});
|
|
1850
|
+
return { promise, resolve };
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function sleep(ms: number): Promise<void> {
|
|
1854
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function splitModel(
|
|
1858
|
+
model: string | undefined,
|
|
1859
|
+
provider: string | undefined,
|
|
1860
|
+
): { providerID?: string; modelID?: string } {
|
|
1861
|
+
if (!model) return {};
|
|
1862
|
+
if (model.includes('/')) {
|
|
1863
|
+
const [providerID, ...rest] = model.split('/');
|
|
1864
|
+
return { providerID, modelID: rest.join('/') };
|
|
1865
|
+
}
|
|
1866
|
+
return { providerID: provider, modelID: model };
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
type OpenCodeModelRef = { providerID: string; modelID: string };
|
|
1870
|
+
|
|
1871
|
+
async function resolveCompactionModel({
|
|
1872
|
+
client,
|
|
1873
|
+
sessionId,
|
|
1874
|
+
start,
|
|
1875
|
+
}: {
|
|
1876
|
+
client: OpenCodeClient;
|
|
1877
|
+
sessionId: string;
|
|
1878
|
+
start: StartMessage;
|
|
1879
|
+
}): Promise<OpenCodeModelRef | undefined> {
|
|
1880
|
+
const assistant = await latestAssistantSnapshot({ client, sessionId }).catch(
|
|
1881
|
+
() => undefined,
|
|
1882
|
+
);
|
|
1883
|
+
const assistantModel = modelRefFromAssistantSnapshot(assistant);
|
|
1884
|
+
if (assistantModel) return assistantModel;
|
|
1885
|
+
|
|
1886
|
+
const session = await legacySessionGet({ client, sessionId }).catch(
|
|
1887
|
+
() => undefined,
|
|
1888
|
+
);
|
|
1889
|
+
const sessionModel = modelRefFromSessionInfo(session?.data);
|
|
1890
|
+
if (sessionModel) return sessionModel;
|
|
1891
|
+
|
|
1892
|
+
return modelRefFromStart(start);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function modelRefFromAssistantSnapshot(
|
|
1896
|
+
assistant: AssistantSnapshot | undefined,
|
|
1897
|
+
): OpenCodeModelRef | undefined {
|
|
1898
|
+
if (!assistant) return undefined;
|
|
1899
|
+
const model = modelRefFromValue(assistant.model);
|
|
1900
|
+
if (model) return model;
|
|
1901
|
+
|
|
1902
|
+
const direct = modelRefFromValue(assistant);
|
|
1903
|
+
if (direct) return direct;
|
|
1904
|
+
|
|
1905
|
+
if (isRecord(assistant.metadata)) {
|
|
1906
|
+
return modelRefFromValue(assistant.metadata.assistant);
|
|
1907
|
+
}
|
|
1908
|
+
return undefined;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function modelRefFromSessionInfo(data: unknown): OpenCodeModelRef | undefined {
|
|
1912
|
+
if (!isRecord(data)) return undefined;
|
|
1913
|
+
return modelRefFromValue(data.model) ?? modelRefFromValue(data);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function modelRefFromStart(start: StartMessage): OpenCodeModelRef | undefined {
|
|
1917
|
+
const model = splitModel(start.model, start.provider);
|
|
1918
|
+
if (!model.modelID) return undefined;
|
|
1919
|
+
return {
|
|
1920
|
+
providerID:
|
|
1921
|
+
model.providerID ?? start.provider ?? procEnv.OPENAI_NAME ?? 'anthropic',
|
|
1922
|
+
modelID: model.modelID,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function modelRefFromValue(value: unknown): OpenCodeModelRef | undefined {
|
|
1927
|
+
if (!isRecord(value)) return undefined;
|
|
1928
|
+
const providerID = stringValue(value.providerID);
|
|
1929
|
+
const modelID = stringValue(value.modelID ?? value.id);
|
|
1930
|
+
if (!providerID || !modelID) return undefined;
|
|
1931
|
+
return { providerID, modelID };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function stringValue(value: unknown): string | undefined {
|
|
1935
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function stripWorkDir(file: string): string {
|
|
1939
|
+
if (!file) return file;
|
|
1940
|
+
const normalized = path.resolve(file);
|
|
1941
|
+
const root = path.resolve(workdir);
|
|
1942
|
+
return normalized.startsWith(`${root}/`)
|
|
1943
|
+
? normalized.slice(root.length + 1)
|
|
1944
|
+
: file;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function parseArgs(args: string[]): {
|
|
1948
|
+
workdir?: string;
|
|
1949
|
+
bridgeStateDir?: string;
|
|
1950
|
+
bootstrapDir?: string;
|
|
1951
|
+
skillsDir?: string;
|
|
1952
|
+
} {
|
|
1953
|
+
const out: {
|
|
1954
|
+
workdir?: string;
|
|
1955
|
+
bridgeStateDir?: string;
|
|
1956
|
+
bootstrapDir?: string;
|
|
1957
|
+
skillsDir?: string;
|
|
1958
|
+
} = {};
|
|
1959
|
+
for (let i = 0; i < args.length; i++) {
|
|
1960
|
+
if (args[i] === '--workdir' && i + 1 < args.length) {
|
|
1961
|
+
out.workdir = args[++i];
|
|
1962
|
+
} else if (args[i] === '--bridge-state-dir' && i + 1 < args.length) {
|
|
1963
|
+
out.bridgeStateDir = args[++i];
|
|
1964
|
+
} else if (args[i] === '--bootstrap-dir' && i + 1 < args.length) {
|
|
1965
|
+
out.bootstrapDir = args[++i];
|
|
1966
|
+
} else if (args[i] === '--skills-dir' && i + 1 < args.length) {
|
|
1967
|
+
out.skillsDir = args[++i];
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return out;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function formatError(error: unknown): string {
|
|
1974
|
+
if (error instanceof Error) return error.message;
|
|
1975
|
+
if (typeof error === 'string') return error;
|
|
1976
|
+
try {
|
|
1977
|
+
return JSON.stringify(error);
|
|
1978
|
+
} catch {
|
|
1979
|
+
return String(error);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function serialiseError(err: unknown): unknown {
|
|
1984
|
+
if (err instanceof Error) {
|
|
1985
|
+
return { name: err.name, message: err.message, stack: err.stack };
|
|
1986
|
+
}
|
|
1987
|
+
return err;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function emitFatal(message: string): never {
|
|
1991
|
+
process.stderr.write(`[opencode bridge] ${message}\n`);
|
|
1992
|
+
process.exit(1);
|
|
1993
|
+
}
|