@ai-sdk/harness-opencode 0.0.0 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +13 -0
- package/README.md +6 -0
- package/dist/bridge/host-tool-mcp.mjs +103 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +1984 -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 +169 -0
- package/dist/index.js +1051 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -1
- package/src/bridge/host-tool-mcp.ts +136 -0
- package/src/bridge/index.ts +1846 -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/index.ts +11 -0
- package/src/opencode-auth.ts +175 -0
- package/src/opencode-bridge-protocol.ts +29 -0
- package/src/opencode-harness.ts +1103 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
commonTool,
|
|
7
|
+
HarnessCapabilityUnsupportedError,
|
|
8
|
+
harnessV1DiagnosticFromBridgeFrame,
|
|
9
|
+
type HarnessV1,
|
|
10
|
+
type HarnessV1Bootstrap,
|
|
11
|
+
type HarnessV1BuiltinTool,
|
|
12
|
+
type HarnessV1ContinueTurnState,
|
|
13
|
+
type HarnessV1DebugConfig,
|
|
14
|
+
type HarnessV1NetworkSandboxSession,
|
|
15
|
+
type HarnessV1PermissionMode,
|
|
16
|
+
type HarnessV1Prompt,
|
|
17
|
+
type HarnessV1PromptControl,
|
|
18
|
+
type HarnessV1ResumeSessionState,
|
|
19
|
+
type HarnessV1Session,
|
|
20
|
+
type HarnessV1Skill,
|
|
21
|
+
type HarnessV1StreamPart,
|
|
22
|
+
} from '@ai-sdk/harness';
|
|
23
|
+
import {
|
|
24
|
+
classifyDiskLog,
|
|
25
|
+
markBridgeStarting,
|
|
26
|
+
SandboxChannel,
|
|
27
|
+
waitForBridgeReady,
|
|
28
|
+
} from '@ai-sdk/harness/utils';
|
|
29
|
+
import {
|
|
30
|
+
tool,
|
|
31
|
+
type Experimental_SandboxProcess,
|
|
32
|
+
type Experimental_SandboxSession,
|
|
33
|
+
} from '@ai-sdk/provider-utils';
|
|
34
|
+
import { WebSocket } from 'ws';
|
|
35
|
+
import { z } from 'zod';
|
|
36
|
+
import {
|
|
37
|
+
resolveOpenCodeEnv,
|
|
38
|
+
splitOpenCodeModel,
|
|
39
|
+
type OpenCodeAuthOptions,
|
|
40
|
+
} from './opencode-auth';
|
|
41
|
+
import {
|
|
42
|
+
outboundMessageSchema,
|
|
43
|
+
type InboundMessage,
|
|
44
|
+
type OutboundMessage,
|
|
45
|
+
} from './opencode-bridge-protocol';
|
|
46
|
+
|
|
47
|
+
type OpenCodeChannel = SandboxChannel<OutboundMessage, InboundMessage>;
|
|
48
|
+
type OpenCodeRespawnStrategy = 'replay' | 'rerun';
|
|
49
|
+
|
|
50
|
+
type WriteSkillsResult = {
|
|
51
|
+
readonly skillsDir: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type OpenCodeHarnessSettings = {
|
|
55
|
+
readonly auth?: OpenCodeAuthOptions;
|
|
56
|
+
readonly model?: string;
|
|
57
|
+
readonly provider?: string;
|
|
58
|
+
/**
|
|
59
|
+
* OpenCode reasoning/thinking variant for reasoning-capable models, e.g.
|
|
60
|
+
* `'low'`, `'medium'`, `'high'`, or another model-supported OpenCode
|
|
61
|
+
* variant.
|
|
62
|
+
*/
|
|
63
|
+
readonly reasoningVariant?: string;
|
|
64
|
+
readonly port?: number;
|
|
65
|
+
readonly startupTimeoutMs?: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const optionalStringRecord = z.record(z.string(), z.unknown()).optional();
|
|
69
|
+
|
|
70
|
+
const OPENCODE_BUILTIN_TOOLS = {
|
|
71
|
+
read: commonTool('read', {
|
|
72
|
+
nativeName: 'view',
|
|
73
|
+
toolUseKind: 'readonly',
|
|
74
|
+
description: 'Read file contents',
|
|
75
|
+
inputSchema: z
|
|
76
|
+
.object({
|
|
77
|
+
file_path: z.string().optional(),
|
|
78
|
+
path: z.string().optional(),
|
|
79
|
+
})
|
|
80
|
+
.passthrough(),
|
|
81
|
+
}),
|
|
82
|
+
write: commonTool('write', {
|
|
83
|
+
nativeName: 'write',
|
|
84
|
+
toolUseKind: 'edit',
|
|
85
|
+
description: 'Write content to a file',
|
|
86
|
+
inputSchema: z
|
|
87
|
+
.object({
|
|
88
|
+
file_path: z.string().optional(),
|
|
89
|
+
path: z.string().optional(),
|
|
90
|
+
content: z.string().optional(),
|
|
91
|
+
})
|
|
92
|
+
.passthrough(),
|
|
93
|
+
}),
|
|
94
|
+
edit: commonTool('edit', {
|
|
95
|
+
nativeName: 'edit',
|
|
96
|
+
toolUseKind: 'edit',
|
|
97
|
+
description: 'Edit a file by replacing text',
|
|
98
|
+
inputSchema: z
|
|
99
|
+
.object({
|
|
100
|
+
file_path: z.string().optional(),
|
|
101
|
+
path: z.string().optional(),
|
|
102
|
+
old_string: z.string().optional(),
|
|
103
|
+
new_string: z.string().optional(),
|
|
104
|
+
})
|
|
105
|
+
.passthrough(),
|
|
106
|
+
}),
|
|
107
|
+
bash: commonTool('bash', {
|
|
108
|
+
nativeName: 'bash',
|
|
109
|
+
toolUseKind: 'bash',
|
|
110
|
+
description: 'Execute a shell command',
|
|
111
|
+
inputSchema: z
|
|
112
|
+
.object({
|
|
113
|
+
command: z.string().optional(),
|
|
114
|
+
})
|
|
115
|
+
.passthrough(),
|
|
116
|
+
}),
|
|
117
|
+
glob: commonTool('glob', {
|
|
118
|
+
nativeName: 'glob',
|
|
119
|
+
toolUseKind: 'readonly',
|
|
120
|
+
description: 'Find files matching a glob pattern',
|
|
121
|
+
inputSchema: z
|
|
122
|
+
.object({
|
|
123
|
+
pattern: z.string().optional(),
|
|
124
|
+
path: z.string().optional(),
|
|
125
|
+
})
|
|
126
|
+
.passthrough(),
|
|
127
|
+
}),
|
|
128
|
+
grep: commonTool('grep', {
|
|
129
|
+
nativeName: 'grep',
|
|
130
|
+
toolUseKind: 'readonly',
|
|
131
|
+
description: 'Search file contents with regex',
|
|
132
|
+
inputSchema: z
|
|
133
|
+
.object({
|
|
134
|
+
pattern: z.string().optional(),
|
|
135
|
+
path: z.string().optional(),
|
|
136
|
+
})
|
|
137
|
+
.passthrough(),
|
|
138
|
+
}),
|
|
139
|
+
ls: tool({
|
|
140
|
+
description: 'List directory contents',
|
|
141
|
+
inputSchema: z
|
|
142
|
+
.object({
|
|
143
|
+
path: z.string().optional(),
|
|
144
|
+
})
|
|
145
|
+
.passthrough(),
|
|
146
|
+
}),
|
|
147
|
+
webfetch: tool({
|
|
148
|
+
description: 'Fetch a URL',
|
|
149
|
+
inputSchema: z
|
|
150
|
+
.object({
|
|
151
|
+
url: z.string().optional(),
|
|
152
|
+
prompt: z.string().optional(),
|
|
153
|
+
})
|
|
154
|
+
.passthrough(),
|
|
155
|
+
}),
|
|
156
|
+
skill: tool({
|
|
157
|
+
description: 'Load an OpenCode skill by name',
|
|
158
|
+
inputSchema: z
|
|
159
|
+
.object({
|
|
160
|
+
name: z.string().optional(),
|
|
161
|
+
})
|
|
162
|
+
.passthrough(),
|
|
163
|
+
}),
|
|
164
|
+
todowrite: tool({
|
|
165
|
+
description: 'Replace the OpenCode session todo list',
|
|
166
|
+
inputSchema: z
|
|
167
|
+
.object({
|
|
168
|
+
todos: z
|
|
169
|
+
.array(
|
|
170
|
+
z
|
|
171
|
+
.object({
|
|
172
|
+
content: z.string().optional(),
|
|
173
|
+
status: z.string().optional(),
|
|
174
|
+
priority: z.string().optional(),
|
|
175
|
+
})
|
|
176
|
+
.passthrough(),
|
|
177
|
+
)
|
|
178
|
+
.optional(),
|
|
179
|
+
})
|
|
180
|
+
.passthrough(),
|
|
181
|
+
}),
|
|
182
|
+
agent: tool({
|
|
183
|
+
description: 'Run an OpenCode subagent',
|
|
184
|
+
inputSchema: z
|
|
185
|
+
.object({
|
|
186
|
+
agent: z.string().optional(),
|
|
187
|
+
prompt: z.string().optional(),
|
|
188
|
+
description: z.string().optional(),
|
|
189
|
+
metadata: optionalStringRecord,
|
|
190
|
+
})
|
|
191
|
+
.passthrough(),
|
|
192
|
+
}),
|
|
193
|
+
} as const satisfies Record<string, HarnessV1BuiltinTool<any, any>>;
|
|
194
|
+
|
|
195
|
+
const BOOTSTRAP_DIR = '/tmp/harness/opencode';
|
|
196
|
+
|
|
197
|
+
const openCodeBridgeCoordsSchema = z.object({
|
|
198
|
+
port: z.number(),
|
|
199
|
+
token: z.string(),
|
|
200
|
+
lastSeenEventId: z.number(),
|
|
201
|
+
sandboxId: z.string().optional(),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const openCodeResumeStateSchema = z.object({
|
|
205
|
+
openCodeSessionId: z.string().optional(),
|
|
206
|
+
bridge: openCodeBridgeCoordsSchema.optional(),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
type OpenCodeBridgeCoords = z.infer<typeof openCodeBridgeCoordsSchema>;
|
|
210
|
+
|
|
211
|
+
export function createOpenCode(
|
|
212
|
+
settings: OpenCodeHarnessSettings = {},
|
|
213
|
+
): HarnessV1<typeof OPENCODE_BUILTIN_TOOLS> {
|
|
214
|
+
let cachedBootstrap: HarnessV1Bootstrap | undefined;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
specificationVersion: 'harness-v1',
|
|
218
|
+
harnessId: 'opencode',
|
|
219
|
+
builtinTools: OPENCODE_BUILTIN_TOOLS,
|
|
220
|
+
supportsBuiltinToolApprovals: true,
|
|
221
|
+
lifecycleStateSchema: openCodeResumeStateSchema,
|
|
222
|
+
getBootstrap: async () => {
|
|
223
|
+
if (cachedBootstrap != null) return cachedBootstrap;
|
|
224
|
+
const [pkg, lock, bridge, hostToolMcp] = await Promise.all([
|
|
225
|
+
readBridgeAsset('package.json'),
|
|
226
|
+
readBridgeAsset('pnpm-lock.yaml'),
|
|
227
|
+
readBridgeAsset('index.mjs'),
|
|
228
|
+
readBridgeAsset('host-tool-mcp.mjs'),
|
|
229
|
+
]);
|
|
230
|
+
cachedBootstrap = {
|
|
231
|
+
harnessId: 'opencode',
|
|
232
|
+
bootstrapDir: BOOTSTRAP_DIR,
|
|
233
|
+
files: [
|
|
234
|
+
{ path: `${BOOTSTRAP_DIR}/package.json`, content: pkg },
|
|
235
|
+
{ path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`, content: lock },
|
|
236
|
+
{ path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge },
|
|
237
|
+
{
|
|
238
|
+
path: `${BOOTSTRAP_DIR}/host-tool-mcp.mjs`,
|
|
239
|
+
content: hostToolMcp,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
commands: [
|
|
243
|
+
{ command: `mkdir -p ${BOOTSTRAP_DIR}` },
|
|
244
|
+
{
|
|
245
|
+
command: `pnpm --dir ${BOOTSTRAP_DIR} install --frozen-lockfile --store-dir ${BOOTSTRAP_DIR}/.pnpm-store`,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
command: `cd ${BOOTSTRAP_DIR} && node node_modules/opencode-ai/postinstall.mjs && ./node_modules/.bin/opencode --version`,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
return cachedBootstrap;
|
|
253
|
+
},
|
|
254
|
+
doStart: async startOpts => {
|
|
255
|
+
const sandboxSession = startOpts.sandboxSession;
|
|
256
|
+
const session = sandboxSession.restricted();
|
|
257
|
+
const sandboxId = sandboxSession.id;
|
|
258
|
+
const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
|
|
259
|
+
const isResume = lifecycleState != null;
|
|
260
|
+
const isContinue = startOpts.continueFrom != null;
|
|
261
|
+
const resumeData =
|
|
262
|
+
isResume && typeof lifecycleState?.data === 'object'
|
|
263
|
+
? (lifecycleState.data as {
|
|
264
|
+
openCodeSessionId?: unknown;
|
|
265
|
+
bridge?: OpenCodeBridgeCoords;
|
|
266
|
+
})
|
|
267
|
+
: undefined;
|
|
268
|
+
const resumeSessionId =
|
|
269
|
+
typeof resumeData?.openCodeSessionId === 'string' &&
|
|
270
|
+
resumeData.openCodeSessionId.length > 0
|
|
271
|
+
? resumeData.openCodeSessionId
|
|
272
|
+
: undefined;
|
|
273
|
+
const coords = resumeData?.bridge;
|
|
274
|
+
|
|
275
|
+
const workDir = startOpts.sessionWorkDir;
|
|
276
|
+
const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
|
|
277
|
+
const bridgeStateDir = `${sessionDataDir}/bridge`;
|
|
278
|
+
const timeoutMs = settings.startupTimeoutMs ?? 120_000;
|
|
279
|
+
const model = splitOpenCodeModel(settings.model, settings.provider).model;
|
|
280
|
+
|
|
281
|
+
const report = startOpts.observability?.report;
|
|
282
|
+
const onDiagnostic = report
|
|
283
|
+
? (frame: Parameters<typeof harnessV1DiagnosticFromBridgeFrame>[0]) =>
|
|
284
|
+
report(
|
|
285
|
+
harnessV1DiagnosticFromBridgeFrame(frame, {
|
|
286
|
+
sessionId: startOpts.sessionId,
|
|
287
|
+
timestamp: Date.now(),
|
|
288
|
+
}),
|
|
289
|
+
)
|
|
290
|
+
: undefined;
|
|
291
|
+
|
|
292
|
+
if (coords) {
|
|
293
|
+
try {
|
|
294
|
+
const attachUrl =
|
|
295
|
+
(await sandboxSession.getPortUrl({
|
|
296
|
+
port: coords.port,
|
|
297
|
+
protocol: 'ws',
|
|
298
|
+
})) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`;
|
|
299
|
+
const attachChannel: OpenCodeChannel = new SandboxChannel({
|
|
300
|
+
connect: () => openWebSocket(attachUrl),
|
|
301
|
+
outboundSchema: outboundMessageSchema,
|
|
302
|
+
initialLastSeenEventId: coords.lastSeenEventId,
|
|
303
|
+
onDiagnostic,
|
|
304
|
+
});
|
|
305
|
+
await attachChannel.open(isContinue ? { resume: true } : undefined);
|
|
306
|
+
return createSession({
|
|
307
|
+
sessionId: startOpts.sessionId,
|
|
308
|
+
channel: attachChannel,
|
|
309
|
+
proc: undefined,
|
|
310
|
+
model,
|
|
311
|
+
provider: settings.provider,
|
|
312
|
+
reasoningVariant: settings.reasoningVariant,
|
|
313
|
+
openCodeSessionId: resumeSessionId,
|
|
314
|
+
isResume: true,
|
|
315
|
+
seedResumeSessionOnFirstPrompt: false,
|
|
316
|
+
rerunContinue: false,
|
|
317
|
+
bridgePort: coords.port,
|
|
318
|
+
bridgeToken: coords.token,
|
|
319
|
+
sandboxId,
|
|
320
|
+
debug: startOpts.observability?.debug,
|
|
321
|
+
permissionMode: startOpts.permissionMode,
|
|
322
|
+
});
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let respawnStrategy: OpenCodeRespawnStrategy | undefined = isResume
|
|
327
|
+
? 'rerun'
|
|
328
|
+
: undefined;
|
|
329
|
+
if (coords && isContinue) {
|
|
330
|
+
const logRaw = await Promise.resolve(
|
|
331
|
+
session.readTextFile({
|
|
332
|
+
path: `${bridgeStateDir}/event-log.ndjson`,
|
|
333
|
+
abortSignal: startOpts.abortSignal,
|
|
334
|
+
}),
|
|
335
|
+
).catch(() => null);
|
|
336
|
+
if ((await classifyDiskLog(logRaw)) === 'replay') {
|
|
337
|
+
respawnStrategy = 'replay';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const port = resolveBridgePort(sandboxSession, settings.port);
|
|
342
|
+
const token = randomBytes(32).toString('hex');
|
|
343
|
+
const sandboxHomeDir = await resolveSandboxHomeDir({
|
|
344
|
+
sandbox: session,
|
|
345
|
+
abortSignal: startOpts.abortSignal,
|
|
346
|
+
});
|
|
347
|
+
const xdgConfigHome = `${sandboxHomeDir}/.config`;
|
|
348
|
+
const xdgCacheHome = `${sandboxHomeDir}/.cache`;
|
|
349
|
+
const xdgDataHome = `${sandboxHomeDir}/.local/share`;
|
|
350
|
+
const xdgStateHome = `${sandboxHomeDir}/.local/state`;
|
|
351
|
+
const skillSetup =
|
|
352
|
+
startOpts.skills && startOpts.skills.length > 0
|
|
353
|
+
? await writeSkills({
|
|
354
|
+
sandbox: session,
|
|
355
|
+
skills: startOpts.skills,
|
|
356
|
+
homeDir: sandboxHomeDir,
|
|
357
|
+
abortSignal: startOpts.abortSignal,
|
|
358
|
+
})
|
|
359
|
+
: undefined;
|
|
360
|
+
const env = {
|
|
361
|
+
...resolveOpenCodeEnv({
|
|
362
|
+
auth: settings.auth,
|
|
363
|
+
model: settings.model,
|
|
364
|
+
provider: settings.provider,
|
|
365
|
+
}),
|
|
366
|
+
BRIDGE_CHANNEL_TOKEN: token,
|
|
367
|
+
BRIDGE_WS_PORT: String(port),
|
|
368
|
+
HOME: sandboxHomeDir,
|
|
369
|
+
USERPROFILE: sandboxHomeDir,
|
|
370
|
+
XDG_CONFIG_HOME: xdgConfigHome,
|
|
371
|
+
XDG_CACHE_HOME: xdgCacheHome,
|
|
372
|
+
XDG_DATA_HOME: xdgDataHome,
|
|
373
|
+
XDG_STATE_HOME: xdgStateHome,
|
|
374
|
+
...(respawnStrategy === 'replay'
|
|
375
|
+
? { BRIDGE_REPLAY_FROM_DISK: '1' }
|
|
376
|
+
: {}),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
if (respawnStrategy === undefined) {
|
|
380
|
+
await session.run({
|
|
381
|
+
command: `mkdir -p ${shellQuote(workDir)} ${shellQuote(bridgeStateDir)} ${shellQuote(xdgConfigHome)} ${shellQuote(xdgCacheHome)} ${shellQuote(xdgDataHome)} ${shellQuote(xdgStateHome)}`,
|
|
382
|
+
abortSignal: startOpts.abortSignal,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await markBridgeStarting({
|
|
387
|
+
sandbox: session,
|
|
388
|
+
bridgeStateDir,
|
|
389
|
+
bridgeType: 'opencode',
|
|
390
|
+
abortSignal: startOpts.abortSignal,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const proc = await session.spawn({
|
|
394
|
+
command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)} --bootstrap-dir ${shellQuote(BOOTSTRAP_DIR)}${skillSetup ? ` --skills-dir ${shellQuote(skillSetup.skillsDir)}` : ''}`,
|
|
395
|
+
env,
|
|
396
|
+
abortSignal: startOpts.abortSignal,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const { port: boundPort } = await waitForBridgeReady({
|
|
400
|
+
proc,
|
|
401
|
+
sandbox: session,
|
|
402
|
+
bridgeStateDir,
|
|
403
|
+
bridgeType: 'opencode',
|
|
404
|
+
timeoutMs,
|
|
405
|
+
abortSignal: startOpts.abortSignal,
|
|
406
|
+
createTimeoutError: () =>
|
|
407
|
+
new Error('opencode bridge did not become ready in time.'),
|
|
408
|
+
createExitError: () =>
|
|
409
|
+
new Error('opencode bridge exited before becoming ready.'),
|
|
410
|
+
});
|
|
411
|
+
void drainRest(proc.stdout);
|
|
412
|
+
void forwardBridgeStderr(proc.stderr);
|
|
413
|
+
|
|
414
|
+
const wsUrl =
|
|
415
|
+
(await sandboxSession.getPortUrl({
|
|
416
|
+
port: boundPort,
|
|
417
|
+
protocol: 'ws',
|
|
418
|
+
})) + `?agent_bridge_token=${encodeURIComponent(token)}`;
|
|
419
|
+
|
|
420
|
+
const channel: OpenCodeChannel = new SandboxChannel({
|
|
421
|
+
connect: () => openWebSocket(wsUrl),
|
|
422
|
+
outboundSchema: outboundMessageSchema,
|
|
423
|
+
onDiagnostic,
|
|
424
|
+
...(respawnStrategy === 'replay'
|
|
425
|
+
? { initialLastSeenEventId: coords?.lastSeenEventId ?? 0 }
|
|
426
|
+
: {}),
|
|
427
|
+
});
|
|
428
|
+
await channel.open(
|
|
429
|
+
respawnStrategy === 'replay' ? { resume: true } : undefined,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return createSession({
|
|
433
|
+
sessionId: startOpts.sessionId,
|
|
434
|
+
channel,
|
|
435
|
+
proc,
|
|
436
|
+
model,
|
|
437
|
+
provider: settings.provider,
|
|
438
|
+
reasoningVariant: settings.reasoningVariant,
|
|
439
|
+
openCodeSessionId: resumeSessionId,
|
|
440
|
+
isResume: respawnStrategy !== undefined,
|
|
441
|
+
seedResumeSessionOnFirstPrompt: respawnStrategy !== undefined,
|
|
442
|
+
rerunContinue: respawnStrategy === 'rerun',
|
|
443
|
+
bridgePort: boundPort,
|
|
444
|
+
bridgeToken: token,
|
|
445
|
+
sandboxId,
|
|
446
|
+
debug: startOpts.observability?.debug,
|
|
447
|
+
permissionMode: startOpts.permissionMode,
|
|
448
|
+
});
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function resolveBridgePort(
|
|
454
|
+
sandboxSession: HarnessV1NetworkSandboxSession,
|
|
455
|
+
override: number | undefined,
|
|
456
|
+
): number {
|
|
457
|
+
if (override !== undefined) return override;
|
|
458
|
+
if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
|
|
459
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
460
|
+
harnessId: 'opencode',
|
|
461
|
+
message:
|
|
462
|
+
'The opencode harness needs a TCP port exposed by the sandbox. ' +
|
|
463
|
+
'Create the sandbox with `ports: [<port>]` or pass `createOpenCode({ port })`.',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function readBridgeAsset(name: string): Promise<string> {
|
|
468
|
+
const candidates = [
|
|
469
|
+
new URL(`./bridge/${name}`, import.meta.url),
|
|
470
|
+
new URL(`../bridge/${name}`, import.meta.url),
|
|
471
|
+
];
|
|
472
|
+
let lastErr: unknown;
|
|
473
|
+
for (const url of candidates) {
|
|
474
|
+
try {
|
|
475
|
+
return await readFile(fileURLToPath(url), 'utf8');
|
|
476
|
+
} catch (err) {
|
|
477
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
478
|
+
if (code !== 'ENOENT') throw err;
|
|
479
|
+
lastErr = err;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
throw lastErr ?? new Error(`bridge asset not found: ${name}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function writeSkills({
|
|
486
|
+
sandbox,
|
|
487
|
+
skills,
|
|
488
|
+
homeDir,
|
|
489
|
+
abortSignal,
|
|
490
|
+
}: {
|
|
491
|
+
sandbox: Experimental_SandboxSession;
|
|
492
|
+
skills: ReadonlyArray<HarnessV1Skill>;
|
|
493
|
+
homeDir: string;
|
|
494
|
+
abortSignal?: AbortSignal;
|
|
495
|
+
}): Promise<WriteSkillsResult> {
|
|
496
|
+
for (const skill of skills) {
|
|
497
|
+
safeOpenCodeSkillName(skill.name);
|
|
498
|
+
for (const file of skill.files ?? []) {
|
|
499
|
+
safeOpenCodeSkillFilePath({ skillName: skill.name, filePath: file.path });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const skillsDir = path.posix.join(homeDir, '.agents', 'skills');
|
|
504
|
+
await sandbox.run({
|
|
505
|
+
command: `mkdir -p ${shellQuote(skillsDir)}`,
|
|
506
|
+
abortSignal,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
for (const skill of skills) {
|
|
510
|
+
const name = safeOpenCodeSkillName(skill.name);
|
|
511
|
+
const skillDir = path.posix.join(skillsDir, name);
|
|
512
|
+
const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
|
|
513
|
+
await sandbox.writeTextFile({
|
|
514
|
+
path: path.posix.join(skillDir, 'SKILL.md'),
|
|
515
|
+
content,
|
|
516
|
+
abortSignal,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
for (const file of skill.files ?? []) {
|
|
520
|
+
const filePath = safeOpenCodeSkillFilePath({
|
|
521
|
+
skillName: skill.name,
|
|
522
|
+
filePath: file.path,
|
|
523
|
+
});
|
|
524
|
+
await sandbox.writeTextFile({
|
|
525
|
+
path: path.posix.join(skillDir, filePath),
|
|
526
|
+
content: file.content,
|
|
527
|
+
abortSignal,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return { skillsDir };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function resolveSandboxHomeDir({
|
|
536
|
+
sandbox,
|
|
537
|
+
abortSignal,
|
|
538
|
+
}: {
|
|
539
|
+
sandbox: Experimental_SandboxSession;
|
|
540
|
+
abortSignal?: AbortSignal;
|
|
541
|
+
}): Promise<string> {
|
|
542
|
+
const result = await sandbox.run({
|
|
543
|
+
command: 'printf "%s" "$HOME"',
|
|
544
|
+
abortSignal,
|
|
545
|
+
});
|
|
546
|
+
const homeDir = result.stdout.trim();
|
|
547
|
+
if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return homeDir;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function safeOpenCodeSkillName(name: string): string {
|
|
556
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
|
|
557
|
+
throw new Error(`Invalid OpenCode skill name: ${name}`);
|
|
558
|
+
}
|
|
559
|
+
return name;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function safeOpenCodeSkillFilePath({
|
|
563
|
+
skillName,
|
|
564
|
+
filePath,
|
|
565
|
+
}: {
|
|
566
|
+
skillName: string;
|
|
567
|
+
filePath: string;
|
|
568
|
+
}): string {
|
|
569
|
+
const normalized = path.posix.normalize(filePath);
|
|
570
|
+
if (
|
|
571
|
+
normalized === '.' ||
|
|
572
|
+
normalized.startsWith('../') ||
|
|
573
|
+
path.posix.isAbsolute(normalized)
|
|
574
|
+
) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`Invalid OpenCode skill file path for ${skillName}: ${filePath}`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return normalized;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function shellQuote(value: string): string {
|
|
583
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function forwardBridgeStderr(
|
|
587
|
+
stream: ReadableStream<Uint8Array>,
|
|
588
|
+
): Promise<void> {
|
|
589
|
+
try {
|
|
590
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
591
|
+
while (true) {
|
|
592
|
+
const { value, done } = await reader.read();
|
|
593
|
+
if (done) return;
|
|
594
|
+
if (value) {
|
|
595
|
+
const trimmed = value.endsWith('\n') ? value.slice(0, -1) : value;
|
|
596
|
+
if (trimmed.length > 0) {
|
|
597
|
+
console.log(`[bridge stderr] ${trimmed}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch {}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function drainRest(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
605
|
+
try {
|
|
606
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
607
|
+
while (true) {
|
|
608
|
+
const { done } = await reader.read();
|
|
609
|
+
if (done) return;
|
|
610
|
+
}
|
|
611
|
+
} catch {}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function openWebSocket(url: string): Promise<WebSocket> {
|
|
615
|
+
return new Promise((resolve, reject) => {
|
|
616
|
+
const ws = new WebSocket(url);
|
|
617
|
+
const onOpen = () => {
|
|
618
|
+
ws.off('error', onError);
|
|
619
|
+
resolve(ws);
|
|
620
|
+
};
|
|
621
|
+
const onError = (err: Error) => {
|
|
622
|
+
ws.off('open', onOpen);
|
|
623
|
+
reject(err);
|
|
624
|
+
};
|
|
625
|
+
ws.once('open', onOpen);
|
|
626
|
+
ws.once('error', onError);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function createSession({
|
|
631
|
+
sessionId,
|
|
632
|
+
channel,
|
|
633
|
+
proc,
|
|
634
|
+
model,
|
|
635
|
+
provider,
|
|
636
|
+
reasoningVariant,
|
|
637
|
+
openCodeSessionId,
|
|
638
|
+
isResume,
|
|
639
|
+
seedResumeSessionOnFirstPrompt,
|
|
640
|
+
rerunContinue,
|
|
641
|
+
bridgePort,
|
|
642
|
+
bridgeToken,
|
|
643
|
+
sandboxId,
|
|
644
|
+
debug,
|
|
645
|
+
permissionMode,
|
|
646
|
+
}: {
|
|
647
|
+
sessionId: string;
|
|
648
|
+
channel: OpenCodeChannel;
|
|
649
|
+
proc: Experimental_SandboxProcess | undefined;
|
|
650
|
+
model: string | undefined;
|
|
651
|
+
provider: string | undefined;
|
|
652
|
+
reasoningVariant: string | undefined;
|
|
653
|
+
openCodeSessionId: string | undefined;
|
|
654
|
+
isResume: boolean;
|
|
655
|
+
seedResumeSessionOnFirstPrompt: boolean;
|
|
656
|
+
rerunContinue: boolean;
|
|
657
|
+
bridgePort: number;
|
|
658
|
+
bridgeToken: string;
|
|
659
|
+
sandboxId: string;
|
|
660
|
+
debug: HarnessV1DebugConfig | undefined;
|
|
661
|
+
permissionMode: HarnessV1PermissionMode | undefined;
|
|
662
|
+
}): HarnessV1Session {
|
|
663
|
+
let stopped = false;
|
|
664
|
+
let stopPromise: Promise<void> | undefined;
|
|
665
|
+
let latestOpenCodeSessionId = openCodeSessionId;
|
|
666
|
+
let pendingResumeSessionId = seedResumeSessionOnFirstPrompt
|
|
667
|
+
? openCodeSessionId
|
|
668
|
+
: undefined;
|
|
669
|
+
let instructionsApplied = isResume;
|
|
670
|
+
let activeTurn = false;
|
|
671
|
+
const pendingCompactionParts: HarnessV1StreamPart[] = [];
|
|
672
|
+
|
|
673
|
+
channel.on('bridge-thread', msg => {
|
|
674
|
+
latestOpenCodeSessionId = msg.threadId;
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const wireTurn = (turnOpts: {
|
|
678
|
+
emit: (event: HarnessV1StreamPart) => void;
|
|
679
|
+
abortSignal?: AbortSignal;
|
|
680
|
+
}): HarnessV1PromptControl => {
|
|
681
|
+
activeTurn = true;
|
|
682
|
+
let pendingResolve: (() => void) | undefined;
|
|
683
|
+
let pendingReject: ((err: unknown) => void) | undefined;
|
|
684
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
685
|
+
pendingResolve = resolve;
|
|
686
|
+
pendingReject = reject;
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const unsubs: Array<() => void> = [];
|
|
690
|
+
const forward = (event: HarnessV1StreamPart) => {
|
|
691
|
+
try {
|
|
692
|
+
turnOpts.emit(event);
|
|
693
|
+
} catch {}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const eventTypes = [
|
|
697
|
+
'stream-start',
|
|
698
|
+
'text-start',
|
|
699
|
+
'text-delta',
|
|
700
|
+
'text-end',
|
|
701
|
+
'reasoning-start',
|
|
702
|
+
'reasoning-delta',
|
|
703
|
+
'reasoning-end',
|
|
704
|
+
'tool-call',
|
|
705
|
+
'tool-approval-request',
|
|
706
|
+
'tool-result',
|
|
707
|
+
'file-change',
|
|
708
|
+
'finish-step',
|
|
709
|
+
'compaction',
|
|
710
|
+
'raw',
|
|
711
|
+
] as const;
|
|
712
|
+
let isSettled = false;
|
|
713
|
+
const settleSuccess = () => {
|
|
714
|
+
if (isSettled) return;
|
|
715
|
+
isSettled = true;
|
|
716
|
+
activeTurn = false;
|
|
717
|
+
for (const u of unsubs) u();
|
|
718
|
+
pendingResolve!();
|
|
719
|
+
};
|
|
720
|
+
const settleError = (err: unknown) => {
|
|
721
|
+
if (isSettled) return;
|
|
722
|
+
isSettled = true;
|
|
723
|
+
activeTurn = false;
|
|
724
|
+
for (const u of unsubs) u();
|
|
725
|
+
pendingReject!(err);
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
for (const type of eventTypes) {
|
|
729
|
+
unsubs.push(
|
|
730
|
+
channel.on(type, msg => {
|
|
731
|
+
forward(msg);
|
|
732
|
+
}),
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
unsubs.push(
|
|
736
|
+
channel.on('finish', msg => {
|
|
737
|
+
forward(msg);
|
|
738
|
+
settleSuccess();
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
unsubs.push(
|
|
742
|
+
channel.on('error', msg => {
|
|
743
|
+
forward(msg);
|
|
744
|
+
settleError(msg.error);
|
|
745
|
+
}),
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const onClose = (_code?: number, reason?: string) => {
|
|
749
|
+
if (isSettled) return;
|
|
750
|
+
if (reason === 'suspended') {
|
|
751
|
+
settleSuccess();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
settleError(
|
|
755
|
+
new Error('opencode bridge closed before the turn finished.'),
|
|
756
|
+
);
|
|
757
|
+
};
|
|
758
|
+
channel.onClose(onClose);
|
|
759
|
+
|
|
760
|
+
const onAbort = () => {
|
|
761
|
+
if (isSettled) return;
|
|
762
|
+
try {
|
|
763
|
+
channel.send({ type: 'abort' });
|
|
764
|
+
} catch {}
|
|
765
|
+
settleError(
|
|
766
|
+
turnOpts.abortSignal?.reason ??
|
|
767
|
+
new DOMException('Aborted', 'AbortError'),
|
|
768
|
+
);
|
|
769
|
+
};
|
|
770
|
+
if (turnOpts.abortSignal) {
|
|
771
|
+
if (turnOpts.abortSignal.aborted) {
|
|
772
|
+
onAbort();
|
|
773
|
+
} else {
|
|
774
|
+
turnOpts.abortSignal.addEventListener('abort', onAbort, {
|
|
775
|
+
once: true,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
while (pendingCompactionParts.length > 0) {
|
|
781
|
+
forward(pendingCompactionParts.shift()!);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
submitToolResult: async input => {
|
|
786
|
+
channel.send({
|
|
787
|
+
type: 'tool-result',
|
|
788
|
+
toolCallId: input.toolCallId,
|
|
789
|
+
output: input.output,
|
|
790
|
+
isError: input.isError,
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
submitToolApproval: async input => {
|
|
794
|
+
channel.send({
|
|
795
|
+
type: 'tool-approval-response',
|
|
796
|
+
approvalId: input.approvalId,
|
|
797
|
+
approved: input.approved,
|
|
798
|
+
reason: input.reason,
|
|
799
|
+
});
|
|
800
|
+
},
|
|
801
|
+
submitUserMessage: async text => {
|
|
802
|
+
channel.send({ type: 'user-message', text });
|
|
803
|
+
},
|
|
804
|
+
done,
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const startBase = () => ({
|
|
809
|
+
model,
|
|
810
|
+
provider,
|
|
811
|
+
...(reasoningVariant ? { variant: reasoningVariant } : {}),
|
|
812
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
813
|
+
...(pendingResumeSessionId
|
|
814
|
+
? { resumeSessionId: pendingResumeSessionId }
|
|
815
|
+
: latestOpenCodeSessionId
|
|
816
|
+
? { resumeSessionId: latestOpenCodeSessionId }
|
|
817
|
+
: {}),
|
|
818
|
+
...(debug ? { debug } : {}),
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
sessionId,
|
|
823
|
+
isResume,
|
|
824
|
+
modelId: model,
|
|
825
|
+
doPromptTurn: async promptOpts => {
|
|
826
|
+
const control = wireTurn({
|
|
827
|
+
emit: promptOpts.emit,
|
|
828
|
+
abortSignal: promptOpts.abortSignal,
|
|
829
|
+
});
|
|
830
|
+
const applyInstructions =
|
|
831
|
+
!instructionsApplied && !!promptOpts.instructions;
|
|
832
|
+
instructionsApplied = true;
|
|
833
|
+
channel.send({
|
|
834
|
+
type: 'start',
|
|
835
|
+
operation: 'prompt',
|
|
836
|
+
prompt: extractUserText(promptOpts.prompt),
|
|
837
|
+
tools: (promptOpts.tools ?? []).map(t => ({
|
|
838
|
+
name: t.name,
|
|
839
|
+
description: t.description,
|
|
840
|
+
inputSchema: t.inputSchema,
|
|
841
|
+
})),
|
|
842
|
+
...(applyInstructions ? { instructions: promptOpts.instructions } : {}),
|
|
843
|
+
...startBase(),
|
|
844
|
+
});
|
|
845
|
+
pendingResumeSessionId = undefined;
|
|
846
|
+
return control;
|
|
847
|
+
},
|
|
848
|
+
doContinueTurn: async continueOpts => {
|
|
849
|
+
const control = wireTurn({
|
|
850
|
+
emit: continueOpts.emit,
|
|
851
|
+
abortSignal: continueOpts.abortSignal,
|
|
852
|
+
});
|
|
853
|
+
if (rerunContinue) {
|
|
854
|
+
channel.send({
|
|
855
|
+
type: 'start',
|
|
856
|
+
operation: 'prompt',
|
|
857
|
+
prompt: 'Continue.',
|
|
858
|
+
tools: (continueOpts.tools ?? []).map(t => ({
|
|
859
|
+
name: t.name,
|
|
860
|
+
description: t.description,
|
|
861
|
+
inputSchema: t.inputSchema,
|
|
862
|
+
})),
|
|
863
|
+
...startBase(),
|
|
864
|
+
});
|
|
865
|
+
pendingResumeSessionId = undefined;
|
|
866
|
+
}
|
|
867
|
+
return control;
|
|
868
|
+
},
|
|
869
|
+
doCompact: async (customInstructions?: string) => {
|
|
870
|
+
if (customInstructions?.trim()) {
|
|
871
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
872
|
+
harnessId: 'opencode',
|
|
873
|
+
message:
|
|
874
|
+
"Harness 'opencode' supports native manual compaction, but OpenCode does not expose custom compaction instructions through the supported API.",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (activeTurn) {
|
|
878
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
879
|
+
harnessId: 'opencode',
|
|
880
|
+
message:
|
|
881
|
+
"Harness 'opencode' supports manual compaction between turns; compacting during an active turn is not supported by the bridge transport.",
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
await runCompactOperation({
|
|
885
|
+
channel,
|
|
886
|
+
model,
|
|
887
|
+
provider,
|
|
888
|
+
permissionMode,
|
|
889
|
+
debug,
|
|
890
|
+
resumeSessionId: latestOpenCodeSessionId,
|
|
891
|
+
onCompaction: part => pendingCompactionParts.push(part),
|
|
892
|
+
});
|
|
893
|
+
},
|
|
894
|
+
doDetach: async () => {
|
|
895
|
+
if (stopped) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`opencode session ${sessionId} is already stopped; cannot detach.`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
stopped = true;
|
|
901
|
+
const lastSeenEventId = await channel.suspend();
|
|
902
|
+
return {
|
|
903
|
+
type: 'resume-session',
|
|
904
|
+
harnessId: 'opencode',
|
|
905
|
+
specificationVersion: 'harness-v1',
|
|
906
|
+
data: {
|
|
907
|
+
...(latestOpenCodeSessionId
|
|
908
|
+
? { openCodeSessionId: latestOpenCodeSessionId }
|
|
909
|
+
: {}),
|
|
910
|
+
bridge: {
|
|
911
|
+
port: bridgePort,
|
|
912
|
+
token: bridgeToken,
|
|
913
|
+
lastSeenEventId,
|
|
914
|
+
sandboxId,
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
doDestroy: async () => {
|
|
920
|
+
if (stopped) return stopPromise;
|
|
921
|
+
stopped = true;
|
|
922
|
+
stopPromise = (async () => {
|
|
923
|
+
channel.beginClose();
|
|
924
|
+
try {
|
|
925
|
+
if (!channel.isClosed()) {
|
|
926
|
+
channel.send({ type: 'shutdown' });
|
|
927
|
+
}
|
|
928
|
+
} catch {}
|
|
929
|
+
let stopTimer: ReturnType<typeof setTimeout> | undefined;
|
|
930
|
+
try {
|
|
931
|
+
if (proc) {
|
|
932
|
+
await Promise.race([
|
|
933
|
+
proc.wait(),
|
|
934
|
+
new Promise<void>(resolve => {
|
|
935
|
+
stopTimer = setTimeout(resolve, 5000);
|
|
936
|
+
stopTimer.unref?.();
|
|
937
|
+
}),
|
|
938
|
+
]);
|
|
939
|
+
}
|
|
940
|
+
} finally {
|
|
941
|
+
if (stopTimer) clearTimeout(stopTimer);
|
|
942
|
+
try {
|
|
943
|
+
await proc?.kill();
|
|
944
|
+
} catch {}
|
|
945
|
+
channel.close();
|
|
946
|
+
}
|
|
947
|
+
})();
|
|
948
|
+
return stopPromise;
|
|
949
|
+
},
|
|
950
|
+
doStop: async () => {
|
|
951
|
+
if (stopped) {
|
|
952
|
+
throw new Error(
|
|
953
|
+
`opencode session ${sessionId} is already stopped; cannot stop.`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
stopped = true;
|
|
957
|
+
channel.beginClose();
|
|
958
|
+
const data: unknown = channel.isClosed()
|
|
959
|
+
? {}
|
|
960
|
+
: await new Promise<unknown>((resolve, reject) => {
|
|
961
|
+
const timer = setTimeout(() => {
|
|
962
|
+
unsub();
|
|
963
|
+
reject(
|
|
964
|
+
new Error(
|
|
965
|
+
`opencode session ${sessionId} did not reply to detach within 5s.`,
|
|
966
|
+
),
|
|
967
|
+
);
|
|
968
|
+
}, 5000);
|
|
969
|
+
timer.unref?.();
|
|
970
|
+
const unsub = channel.on('bridge-detach', msg => {
|
|
971
|
+
clearTimeout(timer);
|
|
972
|
+
unsub();
|
|
973
|
+
resolve(msg.data);
|
|
974
|
+
});
|
|
975
|
+
try {
|
|
976
|
+
channel.send({ type: 'detach' });
|
|
977
|
+
} catch (err) {
|
|
978
|
+
clearTimeout(timer);
|
|
979
|
+
unsub();
|
|
980
|
+
reject(err);
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
let stopTimer: ReturnType<typeof setTimeout> | undefined;
|
|
985
|
+
try {
|
|
986
|
+
if (proc) {
|
|
987
|
+
await Promise.race([
|
|
988
|
+
proc.wait(),
|
|
989
|
+
new Promise<void>(resolve => {
|
|
990
|
+
stopTimer = setTimeout(resolve, 5000);
|
|
991
|
+
stopTimer.unref?.();
|
|
992
|
+
}),
|
|
993
|
+
]);
|
|
994
|
+
}
|
|
995
|
+
} finally {
|
|
996
|
+
if (stopTimer) clearTimeout(stopTimer);
|
|
997
|
+
try {
|
|
998
|
+
await proc?.kill();
|
|
999
|
+
} catch {}
|
|
1000
|
+
channel.close();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const payload: HarnessV1ResumeSessionState = {
|
|
1004
|
+
type: 'resume-session',
|
|
1005
|
+
harnessId: 'opencode',
|
|
1006
|
+
specificationVersion: 'harness-v1',
|
|
1007
|
+
data: (data ?? {}) as HarnessV1ResumeSessionState['data'],
|
|
1008
|
+
};
|
|
1009
|
+
return payload;
|
|
1010
|
+
},
|
|
1011
|
+
doSuspendTurn: async () => {
|
|
1012
|
+
if (stopped) {
|
|
1013
|
+
throw new Error(
|
|
1014
|
+
`opencode session ${sessionId} is stopped; cannot suspend.`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
stopped = true;
|
|
1018
|
+
const lastSeenEventId = await channel.suspend();
|
|
1019
|
+
const payload: HarnessV1ContinueTurnState = {
|
|
1020
|
+
type: 'continue-turn',
|
|
1021
|
+
harnessId: 'opencode',
|
|
1022
|
+
specificationVersion: 'harness-v1',
|
|
1023
|
+
data: {
|
|
1024
|
+
...(latestOpenCodeSessionId
|
|
1025
|
+
? { openCodeSessionId: latestOpenCodeSessionId }
|
|
1026
|
+
: {}),
|
|
1027
|
+
bridge: {
|
|
1028
|
+
port: bridgePort,
|
|
1029
|
+
token: bridgeToken,
|
|
1030
|
+
lastSeenEventId,
|
|
1031
|
+
sandboxId,
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
return payload;
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function runCompactOperation({
|
|
1041
|
+
channel,
|
|
1042
|
+
model,
|
|
1043
|
+
provider,
|
|
1044
|
+
permissionMode,
|
|
1045
|
+
debug,
|
|
1046
|
+
resumeSessionId,
|
|
1047
|
+
onCompaction,
|
|
1048
|
+
}: {
|
|
1049
|
+
channel: OpenCodeChannel;
|
|
1050
|
+
model: string | undefined;
|
|
1051
|
+
provider: string | undefined;
|
|
1052
|
+
permissionMode: HarnessV1PermissionMode | undefined;
|
|
1053
|
+
debug: HarnessV1DebugConfig | undefined;
|
|
1054
|
+
resumeSessionId: string | undefined;
|
|
1055
|
+
onCompaction: (part: HarnessV1StreamPart) => void;
|
|
1056
|
+
}): Promise<void> {
|
|
1057
|
+
let pendingResolve: (() => void) | undefined;
|
|
1058
|
+
let pendingReject: ((err: unknown) => void) | undefined;
|
|
1059
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
1060
|
+
pendingResolve = resolve;
|
|
1061
|
+
pendingReject = reject;
|
|
1062
|
+
});
|
|
1063
|
+
const unsubs = [
|
|
1064
|
+
channel.on('compaction', msg => onCompaction(msg)),
|
|
1065
|
+
channel.on('finish', () => {
|
|
1066
|
+
for (const u of unsubs) u();
|
|
1067
|
+
pendingResolve!();
|
|
1068
|
+
}),
|
|
1069
|
+
channel.on('error', msg => {
|
|
1070
|
+
for (const u of unsubs) u();
|
|
1071
|
+
pendingReject!(msg.error);
|
|
1072
|
+
}),
|
|
1073
|
+
];
|
|
1074
|
+
channel.send({
|
|
1075
|
+
type: 'start',
|
|
1076
|
+
operation: 'compact',
|
|
1077
|
+
prompt: '',
|
|
1078
|
+
tools: [],
|
|
1079
|
+
model,
|
|
1080
|
+
provider,
|
|
1081
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
1082
|
+
...(resumeSessionId ? { resumeSessionId } : {}),
|
|
1083
|
+
...(debug ? { debug } : {}),
|
|
1084
|
+
});
|
|
1085
|
+
await done;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function extractUserText(prompt: HarnessV1Prompt): string {
|
|
1089
|
+
if (typeof prompt === 'string') return prompt;
|
|
1090
|
+
const { content } = prompt;
|
|
1091
|
+
if (typeof content === 'string') return content;
|
|
1092
|
+
const parts: string[] = [];
|
|
1093
|
+
for (const part of content) {
|
|
1094
|
+
if (part.type !== 'text') {
|
|
1095
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
1096
|
+
harnessId: 'opencode',
|
|
1097
|
+
message: `The opencode harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
parts.push(part.text);
|
|
1101
|
+
}
|
|
1102
|
+
return parts.join('\n\n');
|
|
1103
|
+
}
|