@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,2710 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Extracted verbatim from 20x `src/main/adapters/opencode-adapter.ts`.
|
|
4
|
+
* CHANGED (nothing else):
|
|
5
|
+
* - imports: `../utils/opencode-config` -> `./opencode-config`
|
|
6
|
+
* - `import type { DatabaseManager } from '../database'` -> the narrow
|
|
7
|
+
* structural `SettingsReader` type below (`getSetting` is the only member
|
|
8
|
+
* this file ever used; its real signature in 20x `src/main/database.ts` is
|
|
9
|
+
* `getSetting(key: string): string | undefined`).
|
|
10
|
+
* - the enterprise AI gateway is injected: `readEnterpriseAiGatewayConfig(this.db)`
|
|
11
|
+
* is now `this.gateway?.readModelGatewayConfig() ?? null`, because 20x's
|
|
12
|
+
* `enterprise-ai-gateway.ts` decrypts via Electron `safeStorage` and cannot
|
|
13
|
+
* be extracted. The constructor gained an optional second `gateway` param.
|
|
14
|
+
* The `@opencode-ai/sdk` type queries and dynamic imports are UNCHANGED; the
|
|
15
|
+
* SDK is an optional dependency and is declared ambiently in
|
|
16
|
+
* `./optional-agent-sdks.d.ts` so `tsc` succeeds when it is not installed.
|
|
17
|
+
* Source formatting (no semicolons) preserved.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.OpencodeAdapter = void 0;
|
|
21
|
+
const fs_1 = require("fs");
|
|
22
|
+
const path_1 = require("path");
|
|
23
|
+
const os_1 = require("os");
|
|
24
|
+
const opencode_server_policy_1 = require("./opencode-server-policy");
|
|
25
|
+
const opencode_config_1 = require("./opencode-config");
|
|
26
|
+
const model_gateway_1 = require("./model-gateway");
|
|
27
|
+
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
28
|
+
/**
|
|
29
|
+
* A REAL dynamic ESM import, which `await import(...)` is not in this package.
|
|
30
|
+
*
|
|
31
|
+
* This package compiles to CommonJS, and `tsc` downlevels every `await
|
|
32
|
+
* import(x)` into `require(x)`. `@opencode-ai/sdk` is `"type": "module"` and
|
|
33
|
+
* its exports map declares ONLY an `"import"` condition, so the `require`
|
|
34
|
+
* resolution finds no target and Node throws ERR_PACKAGE_PATH_NOT_EXPORTED.
|
|
35
|
+
* The effect is total: `initialize()` then throws "OpenCode SDK not loaded"
|
|
36
|
+
* and NO TURN CAN EVER RUN from a compiled build.
|
|
37
|
+
*
|
|
38
|
+
* Going through `new Function` hides the `import` from the compiler, so it
|
|
39
|
+
* survives into the emitted JavaScript as a genuine ESM import. This is the
|
|
40
|
+
* standard workaround for loading an ESM-only dependency from CommonJS and it
|
|
41
|
+
* is why the specifier must stay a plain runtime string.
|
|
42
|
+
*/
|
|
43
|
+
const esmImport = new Function('specifier', 'return import(specifier)');
|
|
44
|
+
let OpenCodeSDK = null;
|
|
45
|
+
let OpenCodeV2 = null;
|
|
46
|
+
let OpenCodeV2Client = null;
|
|
47
|
+
// THE ONE DELIBERATE CHANGE TO THIS FILE, and it is forced by the sandbox.
|
|
48
|
+
//
|
|
49
|
+
// 20x wrote `import { Agent as UndiciAgent } from 'undici'` and built both
|
|
50
|
+
// dispatchers at module load. That makes `undici` a REQUIRED runtime dependency
|
|
51
|
+
// — and the real-image e2e proved that is fatal: `pf-bootstrap` installs the
|
|
52
|
+
// harness with `--ignore-scripts` from the PRIVATE registry, and the sandbox's
|
|
53
|
+
// egress allowlist does not include registry.npmjs.org. npm therefore cannot
|
|
54
|
+
// resolve `undici` at all, and the boot fails with ETARGET on the private
|
|
55
|
+
// registry or ENOTCACHED on the warm-fallback path. The package must have ZERO
|
|
56
|
+
// required runtime dependencies, which is the property Phase 0 shipped with.
|
|
57
|
+
//
|
|
58
|
+
// So undici is loaded LAZILY and OPTIONALLY. Where it is present (the 20x
|
|
59
|
+
// desktop, any host that installed it) behaviour is byte-identical to 20x.
|
|
60
|
+
// Where it is absent the dispatcher is simply omitted and the platform fetch is
|
|
61
|
+
// used unchanged.
|
|
62
|
+
//
|
|
63
|
+
// THE DEGRADATION IS REAL AND WORTH NAMING: without the dispatcher, undici's
|
|
64
|
+
// built-in 300s headers/body timeout applies to `session.prompt()`, which stays
|
|
65
|
+
// open for the WHOLE agent loop. A turn whose model is silent for more than
|
|
66
|
+
// five minutes — a long tool call, a slow provider — would be cut off. That is
|
|
67
|
+
// why this is a fallback and not the default.
|
|
68
|
+
let undiciAgentCtor;
|
|
69
|
+
function undiciDispatcher(options) {
|
|
70
|
+
if (undiciAgentCtor === undefined) {
|
|
71
|
+
try {
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
+
undiciAgentCtor = require('undici').Agent;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
undiciAgentCtor = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undiciAgentCtor ? new undiciAgentCtor(options) : undefined;
|
|
80
|
+
}
|
|
81
|
+
// Custom fetch with no timeout — used ONLY for session.prompt() which stays open
|
|
82
|
+
// for the entire agent loop (including all tool calls). All other SDK calls use the
|
|
83
|
+
// default fetch which has the SDK's built-in 60s timeout.
|
|
84
|
+
let noTimeoutAgent;
|
|
85
|
+
const noTimeoutFetch = (req) => {
|
|
86
|
+
if (noTimeoutAgent === undefined)
|
|
87
|
+
noTimeoutAgent = undiciDispatcher({ headersTimeout: 0, bodyTimeout: 0 });
|
|
88
|
+
return globalThis.fetch(req, noTimeoutAgent ? { dispatcher: noTimeoutAgent } : {});
|
|
89
|
+
};
|
|
90
|
+
// Fetch with reasonable timeout for quick operations (health checks, config queries, provider listing)
|
|
91
|
+
const QUICK_OP_TIMEOUT_MS = 15_000;
|
|
92
|
+
let quickTimeoutAgent;
|
|
93
|
+
const quickTimeoutFetch = (req) => {
|
|
94
|
+
if (quickTimeoutAgent === undefined)
|
|
95
|
+
quickTimeoutAgent = undiciDispatcher({
|
|
96
|
+
headersTimeout: QUICK_OP_TIMEOUT_MS,
|
|
97
|
+
bodyTimeout: QUICK_OP_TIMEOUT_MS
|
|
98
|
+
});
|
|
99
|
+
return globalThis.fetch(req, quickTimeoutAgent ? { dispatcher: quickTimeoutAgent } : {});
|
|
100
|
+
};
|
|
101
|
+
const DEFAULT_SERVER_URL = 'http://localhost:4096';
|
|
102
|
+
/**
|
|
103
|
+
* Adapter for OpenCode backend
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* A directory this process can ACTUALLY use, for opencode's instance state.
|
|
107
|
+
*
|
|
108
|
+
* `homedir()` alone is not that. In the managed sandbox the harness runs as
|
|
109
|
+
* `pfharness` (uid 1500) while HOME is inherited from the root bootstrap, so
|
|
110
|
+
* `homedir()` answers `/root` — mode 0700 and owned by root. opencode was then
|
|
111
|
+
* told to bootstrap there and every request died:
|
|
112
|
+
*
|
|
113
|
+
* message=fromDirectory directory=/root
|
|
114
|
+
* ERROR PermissionDenied: FileSystem.access (/root/opencode.jsonc)
|
|
115
|
+
* EACCES: permission denied
|
|
116
|
+
*
|
|
117
|
+
* The server started, listened, and answered 500 to its very first
|
|
118
|
+
* `config.get()`. From the outside that reads as "fetch failed" and a turn that
|
|
119
|
+
* completes with no output, which is how it stayed unexplained for so long.
|
|
120
|
+
*
|
|
121
|
+
* So the directory is CHOSEN BY TESTING IT, not assumed: the first candidate
|
|
122
|
+
* this process can read AND write wins. PF_WORKSPACE_ROOT comes first because
|
|
123
|
+
* the sandbox image creates it as pfharness:0700 for exactly this reason.
|
|
124
|
+
*/
|
|
125
|
+
/**
|
|
126
|
+
* Every todo gets a stable id. opencode's `todowrite` input does not carry one.
|
|
127
|
+
*
|
|
128
|
+
* The fallback uses the INDEX AND THE CONTENT, both of which survive the status
|
|
129
|
+
* change that is the only field normally moving — so a streaming update does not
|
|
130
|
+
* remount the list and lose focus and scroll. Matches `claude-code-adapter`'s
|
|
131
|
+
* `id: t.id || 'todo-<i>'`, with the content added because a bare index
|
|
132
|
+
* collides between two todo lists shown in the same transcript.
|
|
133
|
+
*
|
|
134
|
+
* NOTE FOR 20x: `src/main/adapters/opencode-adapter.ts` has the SAME raw
|
|
135
|
+
* pass-through and therefore the same defect on the desktop. This fix belongs
|
|
136
|
+
* there too; the two adapters are copies of each other.
|
|
137
|
+
*/
|
|
138
|
+
function normaliseTodoIds(todos) {
|
|
139
|
+
return todos.map((entry, index) => {
|
|
140
|
+
const todo = (entry ?? {});
|
|
141
|
+
const id = typeof todo.id === 'string' && todo.id.length > 0 ? todo.id : null;
|
|
142
|
+
const content = typeof todo.content === 'string' ? todo.content : '';
|
|
143
|
+
return {
|
|
144
|
+
...todo,
|
|
145
|
+
id: id ?? `todo-${index}-${content}`,
|
|
146
|
+
content,
|
|
147
|
+
status: todo.status ?? 'pending'
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function usableStateDirectory(preferred) {
|
|
152
|
+
const candidates = [
|
|
153
|
+
preferred,
|
|
154
|
+
process.env.PF_WORKSPACE_ROOT,
|
|
155
|
+
// The sandbox image spelled this PLURAL for a long time while the harness
|
|
156
|
+
// read the singular, so the value silently never arrived. Accepting both
|
|
157
|
+
// means a host that sets either one is understood.
|
|
158
|
+
process.env.PF_WORKSPACES_ROOT,
|
|
159
|
+
(0, os_1.homedir)(),
|
|
160
|
+
(0, os_1.tmpdir)()
|
|
161
|
+
].filter((d) => Boolean(d));
|
|
162
|
+
for (const dir of candidates) {
|
|
163
|
+
try {
|
|
164
|
+
(0, fs_1.accessSync)(dir, fs_1.constants.R_OK | fs_1.constants.W_OK);
|
|
165
|
+
return dir;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// tmpdir() is the last candidate and is writable on any sane host; if even
|
|
172
|
+
// that failed, returning it produces the ordinary error rather than a
|
|
173
|
+
// confusing one from a directory nobody chose.
|
|
174
|
+
return (0, os_1.tmpdir)();
|
|
175
|
+
}
|
|
176
|
+
class OpencodeAdapter {
|
|
177
|
+
db;
|
|
178
|
+
gateway;
|
|
179
|
+
serverPolicy;
|
|
180
|
+
/** Callback set by agent-manager to trigger an immediate poll cycle */
|
|
181
|
+
onDataAvailable;
|
|
182
|
+
sdkLoading = null;
|
|
183
|
+
serverInstance = null;
|
|
184
|
+
serverUrl = null;
|
|
185
|
+
serverStarting = null;
|
|
186
|
+
/** The shared V2 SDK client created alongside the server via createOpencode */
|
|
187
|
+
sharedClient = null;
|
|
188
|
+
/** A separate V2 client with a reasonable timeout for quick operations (config, providers, health) */
|
|
189
|
+
quickClient = null;
|
|
190
|
+
clients = new Map(); // sessionId -> ocClient (default timeout, for polling/status/create)
|
|
191
|
+
/** Separate clients with no timeout, used ONLY for session.prompt() which runs indefinitely */
|
|
192
|
+
promptClients = new Map();
|
|
193
|
+
v2Client = null;
|
|
194
|
+
promptAborts = new Map();
|
|
195
|
+
/** Provider errors captured from prompt results (surfaced via getStatus) */
|
|
196
|
+
promptErrors = new Map();
|
|
197
|
+
/** Absolute paths to generated OpenCode plugin files registered for this session/workspace */
|
|
198
|
+
pluginFilePaths = [];
|
|
199
|
+
/** Absolute paths to generated support files used by runtime plugins */
|
|
200
|
+
runtimeSupportFilePaths = [];
|
|
201
|
+
/** Absolute path to the generated tillDone config support file */
|
|
202
|
+
tillDoneConfigPath = null;
|
|
203
|
+
/** Whether the merged config has been pushed at least once to the running server.
|
|
204
|
+
* Config is pushed once on first server connection; subsequent pushes happen only
|
|
205
|
+
* via explicit `notifyConfigChanged()` calls (e.g. after settings edit or key rotation).
|
|
206
|
+
* This avoids PATCH /global/config storms that abort all running sessions. */
|
|
207
|
+
configPushed = false;
|
|
208
|
+
/** Maximum number of automatic retries for transient prompt errors (e.g. "Aborted") */
|
|
209
|
+
static PROMPT_MAX_RETRIES = 3;
|
|
210
|
+
/** Base delay (ms) for exponential backoff between prompt retries */
|
|
211
|
+
static PROMPT_RETRY_BASE_DELAY_MS = 2_000;
|
|
212
|
+
/** Pending permission requests per session (captured from SSE events).
|
|
213
|
+
* Each session may have multiple pending permissions (parallel tool calls). */
|
|
214
|
+
pendingPermissions = new Map();
|
|
215
|
+
/** Abort controller for the SSE event subscription */
|
|
216
|
+
sseAbort = null;
|
|
217
|
+
/** Per-session permission mode ('ask' = surface in UI, 'allow' = auto-approve) */
|
|
218
|
+
sessionPermissionModes = new Map();
|
|
219
|
+
/** Per-session workspace directory — needed for permission replies and other
|
|
220
|
+
* session-scoped V2 API calls initiated from global SSE events. */
|
|
221
|
+
sessionWorkspaceDirs = new Map();
|
|
222
|
+
/** Per-session MCP server configs — retained for re-registration on session resume
|
|
223
|
+
* (e.g. after 20x restart when stdio MCP server processes are dead). */
|
|
224
|
+
sessionMcpConfigs = new Map();
|
|
225
|
+
/** Per-session names of MCP servers that could not be attached. Read by the
|
|
226
|
+
* agent-manager so the session documentation does not advertise tools that
|
|
227
|
+
* are not there. */
|
|
228
|
+
sessionMcpAttachFailures = new Map();
|
|
229
|
+
constructor(db, gateway,
|
|
230
|
+
/**
|
|
231
|
+
* WHOSE OPENCODE SERVER THIS IS. Defaults to `shared`, which is 20x's
|
|
232
|
+
* behaviour and the reason the desktop is not regressed by this change:
|
|
233
|
+
* a host that passes nothing keeps exactly what it had. The HARNESS
|
|
234
|
+
* passes an owned policy (see `cli.ts`), and an owned policy never
|
|
235
|
+
* adopts, never binds a well-known port and never kills anything.
|
|
236
|
+
*/
|
|
237
|
+
serverPolicy = {
|
|
238
|
+
kind: 'shared',
|
|
239
|
+
url: opencode_server_policy_1.SHARED_SERVER_URL
|
|
240
|
+
}) {
|
|
241
|
+
this.db = db;
|
|
242
|
+
this.gateway = gateway;
|
|
243
|
+
this.serverPolicy = serverPolicy;
|
|
244
|
+
this.sdkLoading = this.loadSDK();
|
|
245
|
+
}
|
|
246
|
+
/** The state directory an OWNED server keeps its sessions in, else null. */
|
|
247
|
+
ownedStateDir() {
|
|
248
|
+
return this.serverPolicy.kind === 'owned'
|
|
249
|
+
? this.serverPolicy.stateDir
|
|
250
|
+
: null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* WHICH opencode can honour a `ses_...` handle this adapter minted.
|
|
254
|
+
*
|
|
255
|
+
* Not the process and not the port: two opencode servers sharing one state
|
|
256
|
+
* directory each read the other's sessions immediately, and a session
|
|
257
|
+
* outlives the server that created it. Measured against opencode 1.18.23.
|
|
258
|
+
* So the answer is an identity of the STATE DIRECTORY, and it is stable
|
|
259
|
+
* across both a server restart and a harness restart.
|
|
260
|
+
*/
|
|
261
|
+
backendInstanceId() {
|
|
262
|
+
return (0, opencode_server_policy_1.backendInstanceId)(this.serverPolicy);
|
|
263
|
+
}
|
|
264
|
+
async loadSDK() {
|
|
265
|
+
try {
|
|
266
|
+
OpenCodeSDK = await esmImport('@opencode-ai/sdk');
|
|
267
|
+
OpenCodeV2 = await esmImport('@opencode-ai/sdk/v2');
|
|
268
|
+
OpenCodeV2Client = await esmImport('@opencode-ai/sdk/v2/client');
|
|
269
|
+
console.log('[OpencodeAdapter] SDK loaded successfully (v2 available)');
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
console.error('[OpencodeAdapter] Failed to load SDK:', error);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
this.sdkLoading = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async ensureSDKLoaded() {
|
|
279
|
+
if (OpenCodeSDK)
|
|
280
|
+
return;
|
|
281
|
+
if (this.sdkLoading) {
|
|
282
|
+
await this.sdkLoading;
|
|
283
|
+
}
|
|
284
|
+
if (!OpenCodeSDK) {
|
|
285
|
+
throw new Error('OpenCode SDK not loaded');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async initialize() {
|
|
289
|
+
await this.ensureSDKLoaded();
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Wait for a group of MCP servers to reach a terminal state.
|
|
293
|
+
* Uses one status query per retry cycle (batched) instead of polling per server.
|
|
294
|
+
*/
|
|
295
|
+
async waitForMcpServersReady(ocClient, serverNames, workspaceDir, maxAttempts = 5, delayMs = 300) {
|
|
296
|
+
const pending = new Set(serverNames);
|
|
297
|
+
const states = new Map();
|
|
298
|
+
for (let attempt = 0; attempt < maxAttempts && pending.size > 0; attempt++) {
|
|
299
|
+
try {
|
|
300
|
+
const statusMap = await this.getMcpStatusMap(ocClient, workspaceDir);
|
|
301
|
+
for (const name of [...pending]) {
|
|
302
|
+
const serverStatus = statusMap?.[name];
|
|
303
|
+
if (!serverStatus?.status)
|
|
304
|
+
continue;
|
|
305
|
+
if (serverStatus.status === 'connected') {
|
|
306
|
+
states.set(name, 'connected');
|
|
307
|
+
pending.delete(name);
|
|
308
|
+
console.log(`[OpencodeAdapter] MCP server '${name}' status: connected (attempt ${attempt + 1})`);
|
|
309
|
+
}
|
|
310
|
+
else if (serverStatus.status === 'failed') {
|
|
311
|
+
states.set(name, 'failed');
|
|
312
|
+
pending.delete(name);
|
|
313
|
+
console.error(`[OpencodeAdapter] MCP server '${name}' status: failed${serverStatus.error ? ` - ${serverStatus.error}` : ''}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (statusErr) {
|
|
318
|
+
console.warn('[OpencodeAdapter] Failed to query MCP status:', statusErr);
|
|
319
|
+
}
|
|
320
|
+
if (pending.size > 0 && attempt < maxAttempts - 1 && delayMs > 0) {
|
|
321
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const name of pending) {
|
|
325
|
+
states.set(name, 'timeout');
|
|
326
|
+
}
|
|
327
|
+
return states;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Query MCP status via SDK.
|
|
331
|
+
* Prefer mcp.list() (same view as `opencode mcp list`), fall back to mcp.status().
|
|
332
|
+
*/
|
|
333
|
+
async getMcpStatusMap(ocClient, workspaceDir) {
|
|
334
|
+
const query = workspaceDir ? { query: { directory: workspaceDir } } : {};
|
|
335
|
+
const mcpClient = ocClient.mcp;
|
|
336
|
+
if (typeof mcpClient.list === 'function') {
|
|
337
|
+
try {
|
|
338
|
+
const listResult = await mcpClient.list(query);
|
|
339
|
+
if (!listResult.error) {
|
|
340
|
+
const parsed = this.parseMcpListData(listResult.data);
|
|
341
|
+
if (parsed)
|
|
342
|
+
return parsed;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
console.warn('[OpencodeAdapter] mcp.list failed, falling back to mcp.status:', err);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const statusResult = await mcpClient.status(query);
|
|
350
|
+
return statusResult.data;
|
|
351
|
+
}
|
|
352
|
+
parseMcpListData(data) {
|
|
353
|
+
if (!data)
|
|
354
|
+
return undefined;
|
|
355
|
+
if (Array.isArray(data)) {
|
|
356
|
+
const out = {};
|
|
357
|
+
for (const item of data) {
|
|
358
|
+
const rec = item;
|
|
359
|
+
const name = rec.name || rec.id;
|
|
360
|
+
if (!name)
|
|
361
|
+
continue;
|
|
362
|
+
out[name] = {
|
|
363
|
+
status: rec.status || rec.state || rec.auth?.status,
|
|
364
|
+
error: rec.error || rec.auth?.error
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
368
|
+
}
|
|
369
|
+
if (typeof data === 'object') {
|
|
370
|
+
const obj = data;
|
|
371
|
+
const out = {};
|
|
372
|
+
for (const [name, value] of Object.entries(obj)) {
|
|
373
|
+
out[name] = {
|
|
374
|
+
status: value?.status || value?.state,
|
|
375
|
+
error: value?.error
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Register MCP servers with the OpenCode backend (mcp.add + mcp.connect).
|
|
384
|
+
* Called from both createSession (initial setup) and resumeSession (after 20x
|
|
385
|
+
* restart when stdio MCP server processes are dead and need re-registration).
|
|
386
|
+
*
|
|
387
|
+
* Returns which servers are attached and which are not. The caller MUST act on
|
|
388
|
+
* the failures: OpenCode receives its MCP servers only through these runtime
|
|
389
|
+
* calls, so a swallowed failure produces a session that silently has no tools
|
|
390
|
+
* while AGENTS.md still advertises them.
|
|
391
|
+
*/
|
|
392
|
+
async registerMcpServers(ocClient, mcpServers, workspaceDir) {
|
|
393
|
+
const connectCandidates = [];
|
|
394
|
+
const attached = [];
|
|
395
|
+
const failed = [];
|
|
396
|
+
for (const [name, mcpConfig] of Object.entries(mcpServers)) {
|
|
397
|
+
try {
|
|
398
|
+
const mcpAddConfig = mcpConfig.type === 'http'
|
|
399
|
+
? {
|
|
400
|
+
type: 'remote',
|
|
401
|
+
url: mcpConfig.url ?? '',
|
|
402
|
+
headers: mcpConfig.headers
|
|
403
|
+
}
|
|
404
|
+
: {
|
|
405
|
+
type: 'local',
|
|
406
|
+
command: [mcpConfig.command ?? '', ...(mcpConfig.args ?? [])],
|
|
407
|
+
environment: mcpConfig.env
|
|
408
|
+
};
|
|
409
|
+
console.log(`[OpencodeAdapter] Registering MCP server: ${name}`, JSON.stringify(mcpAddConfig));
|
|
410
|
+
// Add MCP server
|
|
411
|
+
const addResult = await ocClient.mcp.add({
|
|
412
|
+
body: { name, config: mcpAddConfig },
|
|
413
|
+
...(workspaceDir && { query: { directory: workspaceDir } })
|
|
414
|
+
});
|
|
415
|
+
if (addResult.error) {
|
|
416
|
+
console.error(`[OpencodeAdapter] mcp.add error for ${name}:`, addResult.error);
|
|
417
|
+
failed.push(name);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
// Check the add response for immediate server status
|
|
421
|
+
const addStatus = addResult.data?.[name];
|
|
422
|
+
if (addStatus) {
|
|
423
|
+
console.log(`[OpencodeAdapter] mcp.add status for '${name}': ${addStatus.status}${addStatus.error ? ` - ${addStatus.error}` : ''}`);
|
|
424
|
+
if (addStatus.status === 'failed') {
|
|
425
|
+
console.error(`[OpencodeAdapter] MCP server '${name}' failed immediately after add: ${addStatus.error}`);
|
|
426
|
+
failed.push(name);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (addStatus.status === 'connected') {
|
|
430
|
+
console.log(`[OpencodeAdapter] Successfully registered MCP server: ${name} (connected via mcp.add)`);
|
|
431
|
+
attached.push(name);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Connect to MCP server
|
|
436
|
+
const connectResult = await ocClient.mcp.connect({
|
|
437
|
+
path: { name },
|
|
438
|
+
...(workspaceDir && { query: { directory: workspaceDir } })
|
|
439
|
+
});
|
|
440
|
+
if (connectResult.error) {
|
|
441
|
+
console.error(`[OpencodeAdapter] mcp.connect error for ${name}:`, connectResult.error);
|
|
442
|
+
failed.push(name);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// Check connect result (returns boolean)
|
|
446
|
+
if (connectResult.data === false) {
|
|
447
|
+
console.error(`[OpencodeAdapter] mcp.connect returned false for ${name} — server failed to connect`);
|
|
448
|
+
failed.push(name);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
connectCandidates.push(name);
|
|
452
|
+
}
|
|
453
|
+
catch (mcpError) {
|
|
454
|
+
console.error(`[OpencodeAdapter] Failed to register MCP server ${name}:`, mcpError);
|
|
455
|
+
failed.push(name);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (connectCandidates.length > 0) {
|
|
459
|
+
const readiness = await this.waitForMcpServersReady(ocClient, connectCandidates, workspaceDir);
|
|
460
|
+
for (const name of connectCandidates) {
|
|
461
|
+
const state = readiness.get(name);
|
|
462
|
+
if (state === 'connected') {
|
|
463
|
+
console.log(`[OpencodeAdapter] Successfully registered MCP server: ${name}`);
|
|
464
|
+
attached.push(name);
|
|
465
|
+
}
|
|
466
|
+
else if (state === 'failed') {
|
|
467
|
+
console.error(`[OpencodeAdapter] MCP server '${name}' failed to connect`);
|
|
468
|
+
failed.push(name);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
console.error(`[OpencodeAdapter] MCP server '${name}' did not reach connected status — tools may not work`);
|
|
472
|
+
failed.push(name);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return { attached, failed };
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Attach the MCP servers of a session and verify the result.
|
|
480
|
+
*
|
|
481
|
+
* OpenCode keeps MCP servers only in the memory of the `opencode serve`
|
|
482
|
+
* process, so an attach failure is invisible in the session itself: the agent
|
|
483
|
+
* simply has no task-management tools while AGENTS.md advertises them. This
|
|
484
|
+
* helper therefore retries the failures once, and reports what is still
|
|
485
|
+
* missing so the caller can escalate instead of continuing silently.
|
|
486
|
+
*/
|
|
487
|
+
async attachAndVerifyMcpServers(ocClient, mcpServers, workspaceDir, context) {
|
|
488
|
+
const first = await this.registerMcpServers(ocClient, mcpServers, workspaceDir);
|
|
489
|
+
if (first.failed.length === 0)
|
|
490
|
+
return first;
|
|
491
|
+
console.warn(`[OpencodeAdapter] ${context}: MCP servers not attached on first try (${first.failed.join(', ')}) — retrying once`);
|
|
492
|
+
const retryTargets = {};
|
|
493
|
+
for (const name of first.failed) {
|
|
494
|
+
const cfg = mcpServers[name];
|
|
495
|
+
if (cfg)
|
|
496
|
+
retryTargets[name] = cfg;
|
|
497
|
+
}
|
|
498
|
+
const second = await this.registerMcpServers(ocClient, retryTargets, workspaceDir);
|
|
499
|
+
const result = {
|
|
500
|
+
attached: [...first.attached, ...second.attached],
|
|
501
|
+
failed: second.failed
|
|
502
|
+
};
|
|
503
|
+
if (result.failed.length > 0) {
|
|
504
|
+
// Loud, single-line marker: this is the failure mode where an agent runs
|
|
505
|
+
// with no task-management tools while its own AGENTS.md lists 35 of them.
|
|
506
|
+
console.error(`[OpencodeAdapter] MCP ATTACH FAILED — ${context}: ${result.failed.join(', ')} not attached. ` +
|
|
507
|
+
`Agent tools from these servers are NOT available in this session.`);
|
|
508
|
+
}
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
setMcpAttachFailures(sessionId, failed) {
|
|
512
|
+
if (failed.length > 0) {
|
|
513
|
+
this.sessionMcpAttachFailures.set(sessionId, [...failed]);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
this.sessionMcpAttachFailures.delete(sessionId);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Names of MCP servers that are configured for the session but are NOT
|
|
521
|
+
* attached to the OpenCode backend, so none of their tools can be called.
|
|
522
|
+
*/
|
|
523
|
+
getMcpAttachFailures(sessionId) {
|
|
524
|
+
return this.sessionMcpAttachFailures.get(sessionId) ?? [];
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Disconnect the MCP servers that were attached for a session.
|
|
528
|
+
*
|
|
529
|
+
* Each attached stdio server is a child process of the shared `opencode serve`
|
|
530
|
+
* process, and it lives as long as that server does — which is days, because
|
|
531
|
+
* the server is shared and often adopted rather than spawned. Without this the
|
|
532
|
+
* stdio children accumulate one per session (the task-management-mcp leak).
|
|
533
|
+
* Servers still needed by another live session are kept.
|
|
534
|
+
*/
|
|
535
|
+
async disconnectSessionMcpServers(sessionId) {
|
|
536
|
+
const ownConfig = this.sessionMcpConfigs.get(sessionId);
|
|
537
|
+
if (!ownConfig)
|
|
538
|
+
return;
|
|
539
|
+
const stillNeeded = new Set();
|
|
540
|
+
for (const [otherSessionId, otherConfig] of this.sessionMcpConfigs.entries()) {
|
|
541
|
+
if (otherSessionId === sessionId)
|
|
542
|
+
continue;
|
|
543
|
+
for (const name of Object.keys(otherConfig))
|
|
544
|
+
stillNeeded.add(name);
|
|
545
|
+
}
|
|
546
|
+
const client = this.clients.get(sessionId);
|
|
547
|
+
if (!client)
|
|
548
|
+
return;
|
|
549
|
+
const workspaceDir = this.sessionWorkspaceDirs.get(sessionId);
|
|
550
|
+
const mcpClient = client.mcp;
|
|
551
|
+
if (typeof mcpClient.disconnect !== 'function')
|
|
552
|
+
return;
|
|
553
|
+
for (const name of Object.keys(ownConfig)) {
|
|
554
|
+
if (stillNeeded.has(name))
|
|
555
|
+
continue;
|
|
556
|
+
try {
|
|
557
|
+
const result = await mcpClient.disconnect({
|
|
558
|
+
path: { name },
|
|
559
|
+
...(workspaceDir && { query: { directory: workspaceDir } })
|
|
560
|
+
});
|
|
561
|
+
if (result?.error) {
|
|
562
|
+
console.warn(`[OpencodeAdapter] mcp.disconnect error for ${name}:`, result.error);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
console.log(`[OpencodeAdapter] Disconnected MCP server '${name}' for session ${sessionId}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
console.warn(`[OpencodeAdapter] mcp.disconnect failed for ${name}:`, err instanceof Error ? err.message : err);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
getScopedPartId(messageId, rawPartId, fallbackIndex) {
|
|
574
|
+
if (rawPartId)
|
|
575
|
+
return `${messageId}:${rawPartId}`;
|
|
576
|
+
if (fallbackIndex !== undefined)
|
|
577
|
+
return `${messageId}:part-${fallbackIndex}`;
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Returns the shared SDK client, ensuring the server is running first.
|
|
582
|
+
* Pass `quick: true` to get a client with a bounded timeout (for config queries, health checks).
|
|
583
|
+
*/
|
|
584
|
+
async getClient(serverUrl, opts) {
|
|
585
|
+
const baseUrl = serverUrl || this.serverUrl || DEFAULT_SERVER_URL;
|
|
586
|
+
await this.ensureServerRunning(baseUrl);
|
|
587
|
+
if (opts?.quick) {
|
|
588
|
+
if (!this.quickClient) {
|
|
589
|
+
throw new Error('OpenCode quick client not available after server startup');
|
|
590
|
+
}
|
|
591
|
+
return this.quickClient;
|
|
592
|
+
}
|
|
593
|
+
if (!this.sharedClient) {
|
|
594
|
+
throw new Error('OpenCode client not available after server startup');
|
|
595
|
+
}
|
|
596
|
+
return this.sharedClient;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Push the merged provider/auth config to the running OpenCode server.
|
|
600
|
+
*
|
|
601
|
+
* ⚠️ PATCH /global/config causes the server to call disposeAllInstancesAndEmitGlobalDisposed(),
|
|
602
|
+
* which aborts every running session processor AND disconnects all MCP servers.
|
|
603
|
+
*
|
|
604
|
+
* Strategy:
|
|
605
|
+
* - When sessions are active: push via directory-scoped PATCH /config for each
|
|
606
|
+
* active session directory. This updates the config without killing MCP connections.
|
|
607
|
+
* - When no sessions are active: safe to use global endpoint.
|
|
608
|
+
*/
|
|
609
|
+
async pushMergedConfigToClient(client) {
|
|
610
|
+
const hasActivePrompts = this.promptAborts.size > 0;
|
|
611
|
+
try {
|
|
612
|
+
const mergedConfig = (0, opencode_config_1.buildMergedOpencodeConfig)(undefined, this.gateway);
|
|
613
|
+
if (!mergedConfig.provider) {
|
|
614
|
+
console.log('[OpencodeAdapter] pushMergedConfigToClient: no providers in merged config, skipping');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const providerIds = Object.keys(mergedConfig.provider);
|
|
618
|
+
console.log('[OpencodeAdapter] pushMergedConfigToClient: pushing providers:', providerIds.join(', '));
|
|
619
|
+
const castConfig = mergedConfig;
|
|
620
|
+
if (hasActivePrompts) {
|
|
621
|
+
// Sessions are running — use directory-scoped config updates to avoid
|
|
622
|
+
// disposeAllInstances which would kill MCP connections.
|
|
623
|
+
console.log(`[OpencodeAdapter] pushMergedConfigToClient: ${this.promptAborts.size} prompt(s) active — using directory-scoped config push`);
|
|
624
|
+
const directories = new Set(this.sessionWorkspaceDirs.values());
|
|
625
|
+
if (directories.size > 0) {
|
|
626
|
+
for (const dir of directories) {
|
|
627
|
+
try {
|
|
628
|
+
await client.config.update({
|
|
629
|
+
config: castConfig,
|
|
630
|
+
directory: dir
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
// Individual directory push failed — non-fatal
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
// No directories known — fall back to global as last resort
|
|
640
|
+
console.warn('[OpencodeAdapter] pushMergedConfigToClient: no known directories, using global endpoint (may disrupt active sessions)');
|
|
641
|
+
await client.global.config.update({ config: castConfig });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
// No active sessions — safe to use global endpoint
|
|
646
|
+
try {
|
|
647
|
+
const result = await client.global.config.update({
|
|
648
|
+
config: castConfig
|
|
649
|
+
});
|
|
650
|
+
if (result.error) {
|
|
651
|
+
console.warn('[OpencodeAdapter] global.config.update returned error:', JSON.stringify(result.error));
|
|
652
|
+
throw new Error('global.config.update returned error');
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
const result = await client.config.update({ config: castConfig });
|
|
657
|
+
if (result.error) {
|
|
658
|
+
console.warn('[OpencodeAdapter] config.update returned error:', JSON.stringify(result.error));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.configPushed = true;
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
console.warn('[OpencodeAdapter] pushMergedConfigToClient failed:', err instanceof Error ? err.message : err);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Notify the adapter that provider config has changed (e.g. user edited agent settings,
|
|
670
|
+
* AI gateway key was rotated). Pushes the updated config to the running server.
|
|
671
|
+
*
|
|
672
|
+
* Call this from the agent-manager when settings change — do NOT call it on every
|
|
673
|
+
* createSession or getClient, since PATCH /global/config aborts all running sessions.
|
|
674
|
+
*/
|
|
675
|
+
async notifyConfigChanged() {
|
|
676
|
+
if (!this.sharedClient) {
|
|
677
|
+
console.log('[OpencodeAdapter] notifyConfigChanged: no server connection yet, skipping');
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
console.log('[OpencodeAdapter] notifyConfigChanged: pushing updated config to server');
|
|
681
|
+
await this.pushMergedConfigToClient(this.sharedClient);
|
|
682
|
+
}
|
|
683
|
+
async getProviders(serverUrl, directory) {
|
|
684
|
+
return this.getProvidersInner(serverUrl, directory, true);
|
|
685
|
+
}
|
|
686
|
+
async getProvidersInner(serverUrl, directory, allowRecovery = true) {
|
|
687
|
+
try {
|
|
688
|
+
const client = await this.getClient(serverUrl, { quick: true });
|
|
689
|
+
// Push the merged config if it hasn't been pushed yet. This handles the
|
|
690
|
+
// edge case where getProviders is called before any session has started
|
|
691
|
+
// (e.g. the user opens settings immediately after app launch). Once config
|
|
692
|
+
// has been pushed via ensureServerRunning, this is a no-op. Subsequent
|
|
693
|
+
// config changes go through notifyConfigChanged().
|
|
694
|
+
if (!this.configPushed) {
|
|
695
|
+
await this.pushMergedConfigToClient(client);
|
|
696
|
+
}
|
|
697
|
+
// Always pass a writable directory so the OpenCode server doesn't fall
|
|
698
|
+
// back to its CWD (which is read-only on macOS when launched from
|
|
699
|
+
// /Applications). Without this, fromDirectory() in the server tries to
|
|
700
|
+
// create an SQLite DB at the CWD and fails with "disk I/O error".
|
|
701
|
+
const safeDirectory = usableStateDirectory(directory);
|
|
702
|
+
const result = await client.config.providers({
|
|
703
|
+
directory: safeDirectory
|
|
704
|
+
});
|
|
705
|
+
if (result.error) {
|
|
706
|
+
const errorStr = JSON.stringify(result.error);
|
|
707
|
+
// Detect SQLite database errors from the server (corrupted DB, stale WAL
|
|
708
|
+
// files, migration failures from opencode version upgrades).
|
|
709
|
+
// Recovery: kill the stale server, clear its DB, reset state, retry once.
|
|
710
|
+
if (allowRecovery && errorStr.includes('SQLiteError')) {
|
|
711
|
+
console.warn('[OpencodeAdapter] SQLite error from server, attempting recovery:', errorStr);
|
|
712
|
+
await this.recoverFromBrokenServer();
|
|
713
|
+
return this.getProvidersInner(serverUrl, directory, false);
|
|
714
|
+
}
|
|
715
|
+
console.log('[OpencodeAdapter] No providers configured on server:', errorStr);
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
const data = result.data;
|
|
719
|
+
// Filter stale models from the enterprise AI gateway provider.
|
|
720
|
+
// The OpenCode server uses PATCH (merge semantics) for config updates,
|
|
721
|
+
// so models removed from LiteLLM persist in the server's config.
|
|
722
|
+
// We apply a client-side filter using the fresh model list from SQLite
|
|
723
|
+
// (which was just refreshed from LiteLLM by the caller in agent-manager).
|
|
724
|
+
if (data?.providers && this.gateway) {
|
|
725
|
+
this.filterStaleProviderModels(data.providers);
|
|
726
|
+
}
|
|
727
|
+
return data
|
|
728
|
+
? { providers: data.providers || [], default: data.default || {} }
|
|
729
|
+
: null;
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
console.log('[OpencodeAdapter] Could not get providers:', error instanceof Error ? error.message : error);
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Remove models from the enterprise AI gateway provider that are no longer
|
|
738
|
+
* present in the fresh config stored in SQLite. Mutates the providers array
|
|
739
|
+
* in place.
|
|
740
|
+
*/
|
|
741
|
+
filterStaleProviderModels(providers) {
|
|
742
|
+
if (!this.gateway)
|
|
743
|
+
return;
|
|
744
|
+
const freshConfig = this.gateway?.readModelGatewayConfig() ?? null;
|
|
745
|
+
if (!freshConfig?.models)
|
|
746
|
+
return;
|
|
747
|
+
const freshModelIds = new Set(freshConfig.models.map((m) => m.id));
|
|
748
|
+
const provider = providers.find((p) => p.id === model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID);
|
|
749
|
+
if (!provider?.models || typeof provider.models !== 'object')
|
|
750
|
+
return;
|
|
751
|
+
const serverModels = provider.models;
|
|
752
|
+
const filtered = {};
|
|
753
|
+
for (const [modelId, modelConfig] of Object.entries(serverModels)) {
|
|
754
|
+
if (freshModelIds.has(modelId)) {
|
|
755
|
+
filtered[modelId] = modelConfig;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const removed = Object.keys(serverModels).length - Object.keys(filtered).length;
|
|
759
|
+
if (removed > 0) {
|
|
760
|
+
provider.models = filtered;
|
|
761
|
+
console.log(`[OpencodeAdapter] Filtered out ${removed} stale model(s) from ${model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID} provider`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Recover from a broken opencode server by killing it, clearing its
|
|
766
|
+
* corrupted database, and resetting adapter state so the next call
|
|
767
|
+
* spawns a fresh server.
|
|
768
|
+
*/
|
|
769
|
+
async recoverFromBrokenServer() {
|
|
770
|
+
console.log('[OpencodeAdapter] Starting recovery: stopping broken server and clearing database');
|
|
771
|
+
// 1. Stop the server if we spawned it
|
|
772
|
+
try {
|
|
773
|
+
await this.stopServer();
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Already logged in stopServer
|
|
777
|
+
}
|
|
778
|
+
/*
|
|
779
|
+
* 2. NOTHING IS KILLED BY PORT OR BY NAME.
|
|
780
|
+
*
|
|
781
|
+
* This used to run `lsof -ti :4096 | xargs kill -9`, and on a developer's
|
|
782
|
+
* machine that is a SIGKILL to the human's own opencode server — a process
|
|
783
|
+
* this program did not start, holding sessions it does not own. There is no
|
|
784
|
+
* version of "recovery" that justifies it: killing by port kills whoever
|
|
785
|
+
* holds the port, and killing by name kills whoever shares the name.
|
|
786
|
+
*
|
|
787
|
+
* With an OWNED policy there is nothing to kill anyway. `stopServer` above
|
|
788
|
+
* closes the server this instance spawned, by handle, and a handle cannot
|
|
789
|
+
* name someone else's process.
|
|
790
|
+
*/
|
|
791
|
+
/*
|
|
792
|
+
* 3. Clear the database ONLY when this runner owns the state directory.
|
|
793
|
+
*
|
|
794
|
+
* A corrupted database is a real failure mode after a crash or an opencode
|
|
795
|
+
* upgrade, and deleting it is a reasonable repair — of OUR state. Applied
|
|
796
|
+
* to a SHARED server it is not a repair, it is destroying a human's entire
|
|
797
|
+
* opencode history to fix our own process, so the shared policy repairs
|
|
798
|
+
* nothing and says so.
|
|
799
|
+
*/
|
|
800
|
+
const ownedStateDir = this.ownedStateDir();
|
|
801
|
+
if (!ownedStateDir) {
|
|
802
|
+
console.warn('[OpencodeAdapter] Shared opencode server: not clearing a database ' +
|
|
803
|
+
'this runner does not own. Recovery is limited to reconnecting.');
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
const dbFiles = ['opencode.db', 'opencode.db-shm', 'opencode.db-wal'];
|
|
807
|
+
for (const file of dbFiles) {
|
|
808
|
+
const filePath = (0, path_1.join)(ownedStateDir, file);
|
|
809
|
+
try {
|
|
810
|
+
if ((0, fs_1.existsSync)(filePath)) {
|
|
811
|
+
(0, fs_1.unlinkSync)(filePath);
|
|
812
|
+
console.log(`[OpencodeAdapter] Deleted corrupted DB file: ${filePath}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
console.warn(`[OpencodeAdapter] Could not delete ${filePath}:`, err);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// 4. Reset adapter state so the next getClient() call spawns a fresh server
|
|
821
|
+
this.serverUrl = null;
|
|
822
|
+
this.serverInstance = null;
|
|
823
|
+
this.sharedClient = null;
|
|
824
|
+
this.quickClient = null;
|
|
825
|
+
this.v2Client = null;
|
|
826
|
+
this.serverStarting = null;
|
|
827
|
+
this.configPushed = false;
|
|
828
|
+
// Brief pause to let the OS release the port
|
|
829
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
830
|
+
console.log('[OpencodeAdapter] Recovery complete, will spawn fresh server on next call');
|
|
831
|
+
}
|
|
832
|
+
async checkHealth() {
|
|
833
|
+
try {
|
|
834
|
+
const client = await this.getClient(undefined, { quick: true });
|
|
835
|
+
const result = await client.global.health();
|
|
836
|
+
if (result.error) {
|
|
837
|
+
return { available: false, reason: 'Server not responding' };
|
|
838
|
+
}
|
|
839
|
+
const health = result.data;
|
|
840
|
+
console.log('[OpencodeAdapter] Health check OK, version:', health.version);
|
|
841
|
+
return { available: true };
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
return {
|
|
845
|
+
available: false,
|
|
846
|
+
reason: error instanceof Error ? error.message : 'Server not accessible'
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async findAccessibleServer(url) {
|
|
851
|
+
const urls = [url];
|
|
852
|
+
if (url.includes('localhost')) {
|
|
853
|
+
urls.push(url.replace('localhost', '127.0.0.1'));
|
|
854
|
+
}
|
|
855
|
+
else if (url.includes('127.0.0.1')) {
|
|
856
|
+
urls.push(url.replace('127.0.0.1', 'localhost'));
|
|
857
|
+
}
|
|
858
|
+
for (const testUrl of urls) {
|
|
859
|
+
try {
|
|
860
|
+
const response = await fetch(`${testUrl}/global/health`, {
|
|
861
|
+
signal: AbortSignal.timeout(2000)
|
|
862
|
+
});
|
|
863
|
+
if (response.ok) {
|
|
864
|
+
return testUrl;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
// Try next URL
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Ensures common binary install paths (e.g. ~/.opencode/bin) are in PATH
|
|
875
|
+
* so the SDK's createOpencode can find the `opencode` binary.
|
|
876
|
+
*/
|
|
877
|
+
ensureBinaryPaths() {
|
|
878
|
+
const currentPath = process.env.PATH || '';
|
|
879
|
+
// Include user-configured custom binary path (set via deps:setOpencodePath
|
|
880
|
+
// in onboarding). Without this the installer finds opencode but the adapter
|
|
881
|
+
// cannot, because the custom path is only added to PATH during deps:check.
|
|
882
|
+
const customPath = this.db?.getSetting('OPENCODE_BINARY_PATH') ?? null;
|
|
883
|
+
const extraPaths = [
|
|
884
|
+
...(customPath ? [customPath] : []),
|
|
885
|
+
(0, path_1.join)((0, os_1.homedir)(), '.opencode', 'bin'),
|
|
886
|
+
...(process.platform === 'win32'
|
|
887
|
+
? [(0, path_1.join)((0, os_1.homedir)(), 'AppData', 'Roaming', 'npm')]
|
|
888
|
+
: ['/usr/local/bin']),
|
|
889
|
+
(0, path_1.join)((0, os_1.homedir)(), '.local', 'bin')
|
|
890
|
+
].filter((p) => !currentPath.includes(p));
|
|
891
|
+
if (extraPaths.length > 0) {
|
|
892
|
+
process.env.PATH = [...extraPaths, currentPath].join(path_1.delimiter);
|
|
893
|
+
console.log('[OpencodeAdapter] Added binary paths to PATH:', extraPaths);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
setBashTimeoutDefault() {
|
|
897
|
+
// Set bash tool timeout if not already configured.
|
|
898
|
+
// Without this, bash commands inside the agent run indefinitely — a single
|
|
899
|
+
// hung `npm install` or `git clone` will keep the session stuck forever.
|
|
900
|
+
// 10 minutes is generous enough for legitimate long-running commands while
|
|
901
|
+
// preventing truly stuck processes.
|
|
902
|
+
if (!process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS) {
|
|
903
|
+
process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = '600000'; // 10 minutes
|
|
904
|
+
console.log('[OpencodeAdapter] Set OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS=600000 (10min)');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Spawn THIS runner's own opencode server.
|
|
909
|
+
*
|
|
910
|
+
* Three properties, each deliberate:
|
|
911
|
+
* - a port the kernel chose, bound to LOOPBACK ONLY, so it collides with
|
|
912
|
+
* nothing and is reachable from nowhere else;
|
|
913
|
+
* - its own state directory, so its sessions are its own and are not
|
|
914
|
+
* pruned, listed or shared by anybody else's use of opencode;
|
|
915
|
+
* - spawned, so `stopServer` has a `serverInstance` to close and the
|
|
916
|
+
* "adopted, not mine to close" branch is unreachable.
|
|
917
|
+
*
|
|
918
|
+
* The state directory is passed through `XDG_DATA_HOME`, which is how
|
|
919
|
+
* opencode locates `opencode.db` — verified against opencode 1.18.23 by
|
|
920
|
+
* pointing a server at a private directory and finding its database there.
|
|
921
|
+
*/
|
|
922
|
+
async startOwnedServer(stateDir, environment = {}) {
|
|
923
|
+
try {
|
|
924
|
+
(0, fs_1.mkdirSync)(stateDir, { recursive: true, mode: 0o700 });
|
|
925
|
+
/*
|
|
926
|
+
* Set on the harness process because the SDK spawns `opencode serve` as
|
|
927
|
+
* a child and the child inherits this environment. Safe to leave set: an
|
|
928
|
+
* OWNED policy is the only policy that reaches here, and it wants every
|
|
929
|
+
* opencode this process ever starts to use exactly this directory.
|
|
930
|
+
*/
|
|
931
|
+
process.env.XDG_DATA_HOME = stateDir;
|
|
932
|
+
const port = await (0, opencode_server_policy_1.ephemeralLoopbackPort)();
|
|
933
|
+
const extraConfig = {};
|
|
934
|
+
if (this.pluginFilePaths.length > 0) {
|
|
935
|
+
extraConfig.plugin = [...this.pluginFilePaths];
|
|
936
|
+
}
|
|
937
|
+
const mergedConfig = (0, opencode_config_1.buildMergedOpencodeConfig)(extraConfig, this.gateway);
|
|
938
|
+
console.log(`[OpencodeAdapter] Starting OWNED opencode server on 127.0.0.1:${port} ` +
|
|
939
|
+
`(state ${stateDir}); it will never adopt a server it did not spawn`);
|
|
940
|
+
// The SDK has no environment option. Its async create call spawns the
|
|
941
|
+
// child before its first await. Install this turn's values for that spawn
|
|
942
|
+
// only, then restore the harness environment while startup completes.
|
|
943
|
+
const previous = new Map();
|
|
944
|
+
for (const key of [
|
|
945
|
+
'HTTP_PROXY',
|
|
946
|
+
'http_proxy',
|
|
947
|
+
'https_proxy',
|
|
948
|
+
'ALL_PROXY',
|
|
949
|
+
'all_proxy',
|
|
950
|
+
'no_proxy'
|
|
951
|
+
]) {
|
|
952
|
+
previous.set(key, process.env[key]);
|
|
953
|
+
delete process.env[key];
|
|
954
|
+
}
|
|
955
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
956
|
+
if (!previous.has(key))
|
|
957
|
+
previous.set(key, process.env[key]);
|
|
958
|
+
process.env[key] = value;
|
|
959
|
+
}
|
|
960
|
+
const sdk = OpenCodeV2;
|
|
961
|
+
let started;
|
|
962
|
+
try {
|
|
963
|
+
started = sdk.createOpencode({
|
|
964
|
+
hostname: '127.0.0.1',
|
|
965
|
+
port,
|
|
966
|
+
timeout: 30000,
|
|
967
|
+
config: mergedConfig
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
finally {
|
|
971
|
+
for (const [key, value] of previous) {
|
|
972
|
+
if (value === undefined)
|
|
973
|
+
delete process.env[key];
|
|
974
|
+
else
|
|
975
|
+
process.env[key] = value;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const { client, server } = await started;
|
|
979
|
+
this.serverInstance = server;
|
|
980
|
+
this.serverUrl = server.url;
|
|
981
|
+
this.sharedClient = client;
|
|
982
|
+
this.quickClient = OpenCodeV2Client.createOpencodeClient({
|
|
983
|
+
baseUrl: server.url,
|
|
984
|
+
fetch: quickTimeoutFetch
|
|
985
|
+
});
|
|
986
|
+
console.log(`[OpencodeAdapter] OWNED opencode server ready at ${server.url}`);
|
|
987
|
+
}
|
|
988
|
+
finally {
|
|
989
|
+
this.serverStarting = null;
|
|
990
|
+
this.startEventSubscription();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async ensureServerRunning(targetUrl = DEFAULT_SERVER_URL, environment = {}) {
|
|
994
|
+
/*
|
|
995
|
+
* AN OWNED SERVER IGNORES `targetUrl` ENTIRELY.
|
|
996
|
+
*
|
|
997
|
+
* `targetUrl` reaches here from `config.serverUrl || DEFAULT_SERVER_URL`,
|
|
998
|
+
* and DEFAULT_SERVER_URL is the well-known 4096 a human may already be
|
|
999
|
+
* using. When this runner owns its server there is nothing to look for and
|
|
1000
|
+
* nothing to negotiate: it spawns its own on a loopback port the kernel
|
|
1001
|
+
* chose, or it fails. It NEVER probes 4096 and so can never adopt.
|
|
1002
|
+
*/
|
|
1003
|
+
if (this.serverPolicy.kind === 'owned') {
|
|
1004
|
+
if (this.serverUrl && this.serverInstance)
|
|
1005
|
+
return;
|
|
1006
|
+
if (this.serverStarting)
|
|
1007
|
+
return this.serverStarting;
|
|
1008
|
+
await this.ensureSDKLoaded();
|
|
1009
|
+
this.ensureBinaryPaths();
|
|
1010
|
+
this.setBashTimeoutDefault();
|
|
1011
|
+
this.serverStarting = this.startOwnedServer(this.serverPolicy.stateDir, environment);
|
|
1012
|
+
return this.serverStarting;
|
|
1013
|
+
}
|
|
1014
|
+
if (this.serverUrl) {
|
|
1015
|
+
if (this.serverUrl === targetUrl) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (this.serverStarting) {
|
|
1020
|
+
return this.serverStarting;
|
|
1021
|
+
}
|
|
1022
|
+
await this.ensureSDKLoaded();
|
|
1023
|
+
// Ensure common binary install paths are in PATH so the SDK can find `opencode`
|
|
1024
|
+
this.ensureBinaryPaths();
|
|
1025
|
+
this.setBashTimeoutDefault();
|
|
1026
|
+
const isDefaultUrl = targetUrl === DEFAULT_SERVER_URL || targetUrl === 'http://127.0.0.1:4096';
|
|
1027
|
+
this.serverStarting = (async () => {
|
|
1028
|
+
try {
|
|
1029
|
+
const accessibleUrl = await this.findAccessibleServer(targetUrl);
|
|
1030
|
+
if (accessibleUrl) {
|
|
1031
|
+
this.serverUrl = accessibleUrl;
|
|
1032
|
+
this.serverInstance = null;
|
|
1033
|
+
// Create a V2 client for the existing server (has global.health())
|
|
1034
|
+
this.sharedClient = OpenCodeV2Client.createOpencodeClient({
|
|
1035
|
+
baseUrl: accessibleUrl
|
|
1036
|
+
});
|
|
1037
|
+
// Create a separate client with bounded timeout for quick operations
|
|
1038
|
+
this.quickClient = OpenCodeV2Client.createOpencodeClient({
|
|
1039
|
+
baseUrl: accessibleUrl,
|
|
1040
|
+
fetch: quickTimeoutFetch
|
|
1041
|
+
});
|
|
1042
|
+
// Push merged config (with auth.json keys injected) once on first connection
|
|
1043
|
+
// so custom providers like routerAI are properly authenticated.
|
|
1044
|
+
// This is the ONLY automatic config push; subsequent pushes happen only via
|
|
1045
|
+
// explicit notifyConfigChanged() calls to avoid aborting running sessions.
|
|
1046
|
+
try {
|
|
1047
|
+
// Use quickClient to avoid hanging indefinitely when the existing server is slow.
|
|
1048
|
+
await this.pushMergedConfigToClient(this.quickClient);
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
// pushMergedConfigToClient already logs details
|
|
1052
|
+
}
|
|
1053
|
+
// Start SSE subscription for permission events
|
|
1054
|
+
this.startEventSubscription();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (!isDefaultUrl) {
|
|
1058
|
+
throw new Error(`OpenCode server not accessible at ${targetUrl}`);
|
|
1059
|
+
}
|
|
1060
|
+
// Use SDK's createOpencode (starts server + client together, per docs).
|
|
1061
|
+
// The SDK picks up opencode.json automatically; we pass a merged config
|
|
1062
|
+
// that injects auth.json API keys into custom provider options so
|
|
1063
|
+
// providers like routerAI are properly authenticated.
|
|
1064
|
+
const url = new URL(targetUrl);
|
|
1065
|
+
const hostname = url.hostname;
|
|
1066
|
+
const port = parseInt(url.port || '4096', 10);
|
|
1067
|
+
const extraConfig = {};
|
|
1068
|
+
if (this.pluginFilePaths.length > 0) {
|
|
1069
|
+
extraConfig.plugin = [...this.pluginFilePaths];
|
|
1070
|
+
console.log('[OpencodeAdapter] Passing plugins to server config:', this.pluginFilePaths);
|
|
1071
|
+
}
|
|
1072
|
+
const mergedConfig = (0, opencode_config_1.buildMergedOpencodeConfig)(extraConfig, this.gateway);
|
|
1073
|
+
console.log(`[OpencodeAdapter] Creating opencode instance at ${hostname}:${port} via SDK v2 createOpencode`);
|
|
1074
|
+
const { client, server } = await OpenCodeV2.createOpencode({
|
|
1075
|
+
hostname,
|
|
1076
|
+
port,
|
|
1077
|
+
timeout: 10000,
|
|
1078
|
+
config: mergedConfig
|
|
1079
|
+
});
|
|
1080
|
+
this.serverInstance = server;
|
|
1081
|
+
this.serverUrl = server.url;
|
|
1082
|
+
this.sharedClient = client;
|
|
1083
|
+
// Create a separate client with bounded timeout for quick operations
|
|
1084
|
+
this.quickClient = OpenCodeV2Client.createOpencodeClient({
|
|
1085
|
+
baseUrl: server.url,
|
|
1086
|
+
fetch: quickTimeoutFetch
|
|
1087
|
+
});
|
|
1088
|
+
console.log(`[OpencodeAdapter] OpenCode instance created at ${server.url}`);
|
|
1089
|
+
}
|
|
1090
|
+
finally {
|
|
1091
|
+
this.serverStarting = null;
|
|
1092
|
+
// Start SSE subscription for permission events once the server is up
|
|
1093
|
+
this.startEventSubscription();
|
|
1094
|
+
}
|
|
1095
|
+
})();
|
|
1096
|
+
return this.serverStarting;
|
|
1097
|
+
}
|
|
1098
|
+
async createSession(config) {
|
|
1099
|
+
// Write runtime plugin files BEFORE server starts so they are discovered at startup.
|
|
1100
|
+
// Plugins that depend on support files read them dynamically, so updates can take
|
|
1101
|
+
// effect without restarting the server.
|
|
1102
|
+
this.writeRuntimePluginFiles(config);
|
|
1103
|
+
/*
|
|
1104
|
+
* WIRE 5, AND IT MUST BE BEFORE `session.create`.
|
|
1105
|
+
*
|
|
1106
|
+
* Written after the session was created, this file has no effect: OpenCode
|
|
1107
|
+
* resolves a project's config when the session is created, so a permission
|
|
1108
|
+
* block that appears afterwards is simply never consulted. Verified the
|
|
1109
|
+
* hard way — with the write one line later, three real turns ran `ls -a` to
|
|
1110
|
+
* completion in `ask` mode and NOT ONE raised a prompt, while the file on
|
|
1111
|
+
* disk said `"bash": "ask"` the whole time. Do not move this below.
|
|
1112
|
+
*/
|
|
1113
|
+
this.writeSessionPermissionConfig(config.workspaceDir, config.permissionMode || 'ask');
|
|
1114
|
+
await this.ensureServerRunning(config.serverUrl || DEFAULT_SERVER_URL, config.environment);
|
|
1115
|
+
// Config is pushed once on first server connection (ensureServerRunning).
|
|
1116
|
+
// Subsequent config changes (key rotation, settings edit) go through
|
|
1117
|
+
// notifyConfigChanged() called by the agent-manager — NOT here.
|
|
1118
|
+
// Pushing config on every createSession caused a storm of PATCH /global/config
|
|
1119
|
+
// calls that aborted all running sessions when parallel tasks started.
|
|
1120
|
+
const baseUrl = this.serverUrl || config.serverUrl || DEFAULT_SERVER_URL;
|
|
1121
|
+
// Default client uses SDK's built-in timeout (60s) for session create, polling, MCP ops, etc.
|
|
1122
|
+
const ocClient = OpenCodeSDK.createOpencodeClient({ baseUrl });
|
|
1123
|
+
// Separate client with no timeout — used ONLY for session.prompt() which runs indefinitely
|
|
1124
|
+
const promptClient = OpenCodeSDK.createOpencodeClient({
|
|
1125
|
+
baseUrl,
|
|
1126
|
+
fetch: noTimeoutFetch
|
|
1127
|
+
});
|
|
1128
|
+
// Register runtime plugins via directory-scoped config.update().
|
|
1129
|
+
// Using the directory scope avoids side-effects on other sessions — unscoped
|
|
1130
|
+
// config patches can trigger disposeAllInstances on the OpenCode server,
|
|
1131
|
+
// which kills MCP connections for every running session.
|
|
1132
|
+
if (this.pluginFilePaths.length > 0) {
|
|
1133
|
+
try {
|
|
1134
|
+
await ocClient.config.update({
|
|
1135
|
+
body: { plugin: [...this.pluginFilePaths] },
|
|
1136
|
+
...(config.workspaceDir && {
|
|
1137
|
+
query: { directory: config.workspaceDir }
|
|
1138
|
+
})
|
|
1139
|
+
});
|
|
1140
|
+
console.log('[OpencodeAdapter] Registered runtime plugins via config.update:', this.pluginFilePaths);
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
console.warn('[OpencodeAdapter] config.update for runtime plugins failed (will rely on startup loading):', err);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
// Register MCP servers BEFORE creating session so the session picks them up
|
|
1147
|
+
let attachResult = { attached: [], failed: [] };
|
|
1148
|
+
if (config.mcpServers) {
|
|
1149
|
+
attachResult = await this.attachAndVerifyMcpServers(ocClient, config.mcpServers, config.workspaceDir, `createSession task=${config.taskId}`);
|
|
1150
|
+
}
|
|
1151
|
+
// Create OpenCode session
|
|
1152
|
+
const result = await ocClient.session.create({
|
|
1153
|
+
body: { title: `Task ${config.taskId}` },
|
|
1154
|
+
...(config.workspaceDir && { query: { directory: config.workspaceDir } })
|
|
1155
|
+
});
|
|
1156
|
+
if (result.error) {
|
|
1157
|
+
const errData = result.error;
|
|
1158
|
+
throw new Error(errData.data?.message || errData.name || 'Failed to create session');
|
|
1159
|
+
}
|
|
1160
|
+
if (!result.data?.id) {
|
|
1161
|
+
throw new Error('No session ID returned from OpenCode');
|
|
1162
|
+
}
|
|
1163
|
+
const ocSessionId = result.data.id;
|
|
1164
|
+
this.writeTillDoneSessionConfig(ocSessionId, config.tillDone !== false);
|
|
1165
|
+
this.clients.set(ocSessionId, ocClient);
|
|
1166
|
+
this.promptClients.set(ocSessionId, promptClient);
|
|
1167
|
+
this.sessionPermissionModes.set(ocSessionId, config.permissionMode || 'ask');
|
|
1168
|
+
if (config.workspaceDir)
|
|
1169
|
+
this.sessionWorkspaceDirs.set(ocSessionId, config.workspaceDir);
|
|
1170
|
+
if (config.mcpServers)
|
|
1171
|
+
this.sessionMcpConfigs.set(ocSessionId, config.mcpServers);
|
|
1172
|
+
this.setMcpAttachFailures(ocSessionId, attachResult.failed);
|
|
1173
|
+
return ocSessionId;
|
|
1174
|
+
}
|
|
1175
|
+
async resumeSession(sessionId, config) {
|
|
1176
|
+
// Update runtime plugin files before resuming (plugins read support files dynamically)
|
|
1177
|
+
this.writeRuntimePluginFiles(config);
|
|
1178
|
+
await this.ensureServerRunning(config.serverUrl || DEFAULT_SERVER_URL, config.environment);
|
|
1179
|
+
const baseUrl = this.serverUrl || config.serverUrl || DEFAULT_SERVER_URL;
|
|
1180
|
+
// Default client uses SDK's built-in timeout for polling/status
|
|
1181
|
+
const ocClient = OpenCodeSDK.createOpencodeClient({ baseUrl });
|
|
1182
|
+
// Separate client with no timeout for session.prompt() only
|
|
1183
|
+
const promptClient = OpenCodeSDK.createOpencodeClient({
|
|
1184
|
+
baseUrl,
|
|
1185
|
+
fetch: noTimeoutFetch
|
|
1186
|
+
});
|
|
1187
|
+
// Register runtime plugins via directory-scoped config.update() to avoid
|
|
1188
|
+
// side-effects on other sessions (see createSession comment).
|
|
1189
|
+
if (this.pluginFilePaths.length > 0) {
|
|
1190
|
+
try {
|
|
1191
|
+
await ocClient.config.update({
|
|
1192
|
+
body: { plugin: [...this.pluginFilePaths] },
|
|
1193
|
+
...(config.workspaceDir && {
|
|
1194
|
+
query: { directory: config.workspaceDir }
|
|
1195
|
+
})
|
|
1196
|
+
});
|
|
1197
|
+
console.log('[OpencodeAdapter] Registered runtime plugins via config.update:', this.pluginFilePaths);
|
|
1198
|
+
}
|
|
1199
|
+
catch (err) {
|
|
1200
|
+
console.warn('[OpencodeAdapter] config.update for runtime plugins failed:', err);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// Re-register MCP servers — after a 20x restart, stdio MCP server
|
|
1204
|
+
// processes (task-management, GCP logs) are dead and need re-registration.
|
|
1205
|
+
// Remote MCP servers may also have lost their SSE connections.
|
|
1206
|
+
// OpenCode handles transient mid-session reconnections internally, so this
|
|
1207
|
+
// is only needed on resume, not during normal operation.
|
|
1208
|
+
if (config.mcpServers) {
|
|
1209
|
+
const attachResult = await this.attachAndVerifyMcpServers(ocClient, config.mcpServers, config.workspaceDir, `resumeSession session=${sessionId}`);
|
|
1210
|
+
this.setMcpAttachFailures(sessionId, attachResult.failed);
|
|
1211
|
+
}
|
|
1212
|
+
// Validate session exists
|
|
1213
|
+
const getResult = await ocClient.session.get({
|
|
1214
|
+
path: { id: sessionId },
|
|
1215
|
+
...(config.workspaceDir && { query: { directory: config.workspaceDir } })
|
|
1216
|
+
});
|
|
1217
|
+
if (getResult.error || !getResult.data) {
|
|
1218
|
+
throw new Error('Session no longer exists on server');
|
|
1219
|
+
}
|
|
1220
|
+
// ── Clean up stale session state from previous app instance ──
|
|
1221
|
+
// When the app restarts and resumes a session, tool calls from the
|
|
1222
|
+
// previous instance may still be in "running" state. The OpenCode
|
|
1223
|
+
// server reports these as "busy" even though nothing is actually
|
|
1224
|
+
// executing. This blocks new prompts and aborts.
|
|
1225
|
+
//
|
|
1226
|
+
// Fix: abort any in-progress prompt, then delete zombie "running"
|
|
1227
|
+
// tool parts via V2 part.delete.
|
|
1228
|
+
try {
|
|
1229
|
+
await ocClient.session.abort({
|
|
1230
|
+
path: { id: sessionId },
|
|
1231
|
+
...(config.workspaceDir && {
|
|
1232
|
+
query: { directory: config.workspaceDir }
|
|
1233
|
+
})
|
|
1234
|
+
});
|
|
1235
|
+
console.log(`[OpencodeAdapter] Aborted any in-progress prompt on resume for session ${sessionId}`);
|
|
1236
|
+
}
|
|
1237
|
+
catch {
|
|
1238
|
+
// Non-fatal: session may already be idle
|
|
1239
|
+
}
|
|
1240
|
+
// Delete zombie "running" tool parts that survived the abort.
|
|
1241
|
+
// When a prompt is aborted, individual tool parts remain in
|
|
1242
|
+
// "running" state permanently. The server counts them as active
|
|
1243
|
+
// work, so the session stays "busy" forever — a catch-22 that
|
|
1244
|
+
// prevents both new prompts and message deletion.
|
|
1245
|
+
// Using v2.part.delete is the only way to clear them.
|
|
1246
|
+
try {
|
|
1247
|
+
if (OpenCodeV2Client) {
|
|
1248
|
+
const v2 = this.v2Client ||
|
|
1249
|
+
OpenCodeV2Client.createOpencodeClient({
|
|
1250
|
+
baseUrl: this.serverUrl || DEFAULT_SERVER_URL
|
|
1251
|
+
});
|
|
1252
|
+
if (!this.v2Client)
|
|
1253
|
+
this.v2Client = v2;
|
|
1254
|
+
// Scan messages for zombie running tool parts
|
|
1255
|
+
const msgsResult = await ocClient.session.messages({
|
|
1256
|
+
path: { id: sessionId },
|
|
1257
|
+
...(config.workspaceDir && {
|
|
1258
|
+
query: { directory: config.workspaceDir }
|
|
1259
|
+
})
|
|
1260
|
+
});
|
|
1261
|
+
let zombieCount = 0;
|
|
1262
|
+
if (msgsResult.data && Array.isArray(msgsResult.data)) {
|
|
1263
|
+
for (const msg of msgsResult.data) {
|
|
1264
|
+
const msgId = msg.info
|
|
1265
|
+
? msg.info.id
|
|
1266
|
+
: undefined;
|
|
1267
|
+
if (!msgId)
|
|
1268
|
+
continue;
|
|
1269
|
+
for (const part of msg.parts || []) {
|
|
1270
|
+
if (part.type !== 'tool')
|
|
1271
|
+
continue;
|
|
1272
|
+
const state = part.state;
|
|
1273
|
+
if (state?.status !== 'running')
|
|
1274
|
+
continue;
|
|
1275
|
+
try {
|
|
1276
|
+
await v2.part.delete({
|
|
1277
|
+
sessionID: sessionId,
|
|
1278
|
+
messageID: msgId,
|
|
1279
|
+
partID: part.id,
|
|
1280
|
+
...(config.workspaceDir && { directory: config.workspaceDir })
|
|
1281
|
+
});
|
|
1282
|
+
zombieCount++;
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
// Part may already have been cleaned up
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (zombieCount > 0) {
|
|
1291
|
+
console.log(`[OpencodeAdapter] Deleted ${zombieCount} zombie running tool part(s) on resume for session ${sessionId}`);
|
|
1292
|
+
// Abort again after cleanup to transition the server from busy → idle
|
|
1293
|
+
try {
|
|
1294
|
+
await ocClient.session.abort({
|
|
1295
|
+
path: { id: sessionId },
|
|
1296
|
+
...(config.workspaceDir && {
|
|
1297
|
+
query: { directory: config.workspaceDir }
|
|
1298
|
+
})
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
// Non-fatal
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/*
|
|
1306
|
+
* CLEAR STALE PENDING PERMISSIONS — AND THE ANSWER DEPENDS ON THE MODE.
|
|
1307
|
+
*
|
|
1308
|
+
* THIS USED TO AUTO-APPROVE, UNCONDITIONALLY, WITH `reply: 'always'`.
|
|
1309
|
+
* It was harmless while nothing ever raised a permission: OpenCode was
|
|
1310
|
+
* never configured to ask (see `writeSessionPermissionConfig`), so this
|
|
1311
|
+
* loop had nothing to find. Making permissions real turned it into a
|
|
1312
|
+
* live bypass — a harness crash or restart while a genuine "allow this
|
|
1313
|
+
* shell command?" prompt was outstanding would silently grant it BEFORE
|
|
1314
|
+
* THE USER EVER SAW IT, and `'always'` would suppress every future
|
|
1315
|
+
* prompt for that pattern too.
|
|
1316
|
+
*
|
|
1317
|
+
* That is exactly the unattended-access defect G1 exists to close,
|
|
1318
|
+
* reappearing through the resume path, and it was reachable only
|
|
1319
|
+
* because of the fix. Found in review, not by a test — the resume path
|
|
1320
|
+
* has no coverage for it, which is recorded rather than hidden.
|
|
1321
|
+
*
|
|
1322
|
+
* REJECT, for an asking session. The permission belongs to a turn that
|
|
1323
|
+
* has already ended; neither answer resurrects it, so the choice is
|
|
1324
|
+
* purely which way to fail. A rejected tool call is recoverable — the
|
|
1325
|
+
* user asks again. An approved one is not.
|
|
1326
|
+
*
|
|
1327
|
+
* An `allow` session is the one case where approving is what the user
|
|
1328
|
+
* actually chose, and it matches what `handleServerEvent` does live.
|
|
1329
|
+
*/
|
|
1330
|
+
const resumeMode = config.permissionMode || 'ask';
|
|
1331
|
+
const staleReply = resumeMode === 'allow' ? 'always' : 'reject';
|
|
1332
|
+
const listResult = await v2.permission.list({});
|
|
1333
|
+
if (listResult.data && Array.isArray(listResult.data)) {
|
|
1334
|
+
const allPending = listResult.data;
|
|
1335
|
+
const sessionPending = allPending.filter((p) => p.sessionID === sessionId);
|
|
1336
|
+
for (const perm of sessionPending) {
|
|
1337
|
+
try {
|
|
1338
|
+
await v2.permission.reply({
|
|
1339
|
+
requestID: perm.id,
|
|
1340
|
+
reply: staleReply
|
|
1341
|
+
});
|
|
1342
|
+
console.log(`[OpencodeAdapter] Cleared stale permission ${perm.id} on resume with "${staleReply}" (permissionMode=${resumeMode})`);
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
// Permission may have already expired
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
catch (err) {
|
|
1352
|
+
console.warn(`[OpencodeAdapter] Failed to clean up stale session state on resume:`, err instanceof Error ? err.message : err);
|
|
1353
|
+
}
|
|
1354
|
+
this.clients.set(sessionId, ocClient);
|
|
1355
|
+
this.promptClients.set(sessionId, promptClient);
|
|
1356
|
+
this.sessionPermissionModes.set(sessionId, config.permissionMode || 'ask');
|
|
1357
|
+
if (config.workspaceDir)
|
|
1358
|
+
this.sessionWorkspaceDirs.set(sessionId, config.workspaceDir);
|
|
1359
|
+
if (config.mcpServers)
|
|
1360
|
+
this.sessionMcpConfigs.set(sessionId, config.mcpServers);
|
|
1361
|
+
// WIRE 5, on the resume path too. A workspace is rebuilt per turn, so a
|
|
1362
|
+
// resumed session that skipped this would silently run unrestricted — the
|
|
1363
|
+
// defect returning on the SECOND message of every conversation.
|
|
1364
|
+
this.writeSessionPermissionConfig(config.workspaceDir, config.permissionMode || 'ask');
|
|
1365
|
+
this.writeTillDoneSessionConfig(sessionId, config.tillDone !== false);
|
|
1366
|
+
// Fetch existing messages
|
|
1367
|
+
const messagesResult = await ocClient.session.messages({
|
|
1368
|
+
path: { id: sessionId },
|
|
1369
|
+
...(config.workspaceDir && { query: { directory: config.workspaceDir } })
|
|
1370
|
+
});
|
|
1371
|
+
const messages = [];
|
|
1372
|
+
if (messagesResult.data && Array.isArray(messagesResult.data)) {
|
|
1373
|
+
for (const msg of messagesResult.data) {
|
|
1374
|
+
if (!msg.info)
|
|
1375
|
+
continue;
|
|
1376
|
+
const rawParts = msg.parts || [];
|
|
1377
|
+
const transformedParts = rawParts.map((part, partIndex) => {
|
|
1378
|
+
const scopedPartId = this.getScopedPartId(String(msg.info.id), part.id, partIndex);
|
|
1379
|
+
if (part.type === 'tool') {
|
|
1380
|
+
return {
|
|
1381
|
+
...this.transformToolPart(part),
|
|
1382
|
+
id: scopedPartId
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
id: scopedPartId,
|
|
1387
|
+
type: part.type,
|
|
1388
|
+
text: part.text,
|
|
1389
|
+
content: part.text
|
|
1390
|
+
};
|
|
1391
|
+
});
|
|
1392
|
+
// Surface provider errors stored in msg.info.error (e.g. "Payment
|
|
1393
|
+
// Required", quota exceeded). OpenCode records these on the message
|
|
1394
|
+
// info but creates no parts for them, so without this they vanish on
|
|
1395
|
+
// resume.
|
|
1396
|
+
const msgError = msg.info.error;
|
|
1397
|
+
if (msgError && transformedParts.length === 0) {
|
|
1398
|
+
const errorText = msgError.data?.message || msgError.name || 'Unknown provider error';
|
|
1399
|
+
transformedParts.push({
|
|
1400
|
+
id: `error-${msg.info.id}`,
|
|
1401
|
+
type: 'text',
|
|
1402
|
+
text: `⚠️ Provider error: ${errorText}`,
|
|
1403
|
+
content: `⚠️ Provider error: ${errorText}`
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
messages.push({
|
|
1407
|
+
id: msg.info.id,
|
|
1408
|
+
role: (msg.info.role || 'assistant'),
|
|
1409
|
+
parts: transformedParts
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return messages;
|
|
1414
|
+
}
|
|
1415
|
+
async sendPrompt(sessionId, parts, config) {
|
|
1416
|
+
// Use the no-timeout prompt client for session.prompt() which runs indefinitely.
|
|
1417
|
+
// Falls back to the default client if promptClients entry is missing (shouldn't happen).
|
|
1418
|
+
const ocClient = this.promptClients.get(sessionId) || this.clients.get(sessionId);
|
|
1419
|
+
if (!ocClient) {
|
|
1420
|
+
throw new Error(`No client found for session ${sessionId}`);
|
|
1421
|
+
}
|
|
1422
|
+
// Parse model from config
|
|
1423
|
+
let modelParam;
|
|
1424
|
+
if (config.model) {
|
|
1425
|
+
const slashIdx = config.model.indexOf('/');
|
|
1426
|
+
if (slashIdx > 0) {
|
|
1427
|
+
modelParam = {
|
|
1428
|
+
providerID: config.model.slice(0, slashIdx),
|
|
1429
|
+
modelID: config.model.slice(slashIdx + 1)
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const promptAbort = new AbortController();
|
|
1434
|
+
this.promptAborts.set(sessionId, promptAbort);
|
|
1435
|
+
// Fire-and-forget prompt with retry — the HTTP call stays open until the full
|
|
1436
|
+
// agent loop completes (including tool call execution). getStatus() checks
|
|
1437
|
+
// promptAborts to reliably report BUSY while this call is in flight.
|
|
1438
|
+
// Retries handle transient "Aborted" errors caused by config pushes or
|
|
1439
|
+
// provider concurrency limits.
|
|
1440
|
+
console.log(`[OpencodeAdapter] Sending prompt for session ${sessionId} (model: ${config.model || 'default'})`);
|
|
1441
|
+
this.executePromptWithRetry(ocClient, sessionId, parts, modelParam, config, promptAbort).finally(() => {
|
|
1442
|
+
this.promptAborts.delete(sessionId);
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Returns true if the error message indicates a transient condition that
|
|
1447
|
+
* may succeed on retry (e.g. OpenCode server aborted the session processor
|
|
1448
|
+
* due to a config push, or the provider returned a temporary overload).
|
|
1449
|
+
*/
|
|
1450
|
+
isRetryablePromptError(msg) {
|
|
1451
|
+
const lower = msg.toLowerCase();
|
|
1452
|
+
return (lower === 'aborted' ||
|
|
1453
|
+
lower.includes('aborted') ||
|
|
1454
|
+
lower.includes('overloaded') ||
|
|
1455
|
+
lower.includes('service unavailable'));
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Executes a prompt with automatic retry for transient errors.
|
|
1459
|
+
* Keeps the AbortController in promptAborts alive during retries so
|
|
1460
|
+
* getStatus() continues to report BUSY.
|
|
1461
|
+
*/
|
|
1462
|
+
async executePromptWithRetry(ocClient, sessionId, parts, modelParam, config, promptAbort) {
|
|
1463
|
+
const maxRetries = OpencodeAdapter.PROMPT_MAX_RETRIES;
|
|
1464
|
+
const baseDelay = OpencodeAdapter.PROMPT_RETRY_BASE_DELAY_MS;
|
|
1465
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1466
|
+
if (promptAbort.signal.aborted)
|
|
1467
|
+
return;
|
|
1468
|
+
const promptStartTime = Date.now();
|
|
1469
|
+
if (attempt > 0) {
|
|
1470
|
+
console.log(`[OpencodeAdapter] Retry attempt ${attempt}/${maxRetries} for session ${sessionId}`);
|
|
1471
|
+
}
|
|
1472
|
+
try {
|
|
1473
|
+
const result = await ocClient.session.prompt({
|
|
1474
|
+
path: { id: sessionId },
|
|
1475
|
+
body: {
|
|
1476
|
+
parts: parts,
|
|
1477
|
+
...(modelParam && { model: modelParam }),
|
|
1478
|
+
...(config.tools && { tools: config.tools })
|
|
1479
|
+
},
|
|
1480
|
+
...(config.workspaceDir && {
|
|
1481
|
+
query: { directory: config.workspaceDir }
|
|
1482
|
+
}),
|
|
1483
|
+
signal: promptAbort.signal
|
|
1484
|
+
});
|
|
1485
|
+
const elapsed = Date.now() - promptStartTime;
|
|
1486
|
+
console.log(`[OpencodeAdapter] Prompt completed for session ${sessionId} after ${elapsed}ms`);
|
|
1487
|
+
// Log tool call details from the response for debugging
|
|
1488
|
+
const res = result;
|
|
1489
|
+
if (res?.data?.parts) {
|
|
1490
|
+
const toolParts = res.data.parts.filter((p) => p.type === 'tool');
|
|
1491
|
+
if (toolParts.length > 0) {
|
|
1492
|
+
console.log(`[OpencodeAdapter] Response contains ${toolParts.length} tool part(s):`, toolParts.map((p) => ({
|
|
1493
|
+
tool: p.tool,
|
|
1494
|
+
status: p.state?.status
|
|
1495
|
+
})));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
// Check for provider errors in the prompt response (e.g. quota exceeded,
|
|
1499
|
+
// payment required, rate limit). OpenCode wraps these in result.data.info.error
|
|
1500
|
+
// but does NOT create a message with the error text, so pollMessages never
|
|
1501
|
+
// picks them up and the user sees "idle" with no response.
|
|
1502
|
+
const r = result;
|
|
1503
|
+
const promptError = r?.data?.info?.error;
|
|
1504
|
+
if (promptError) {
|
|
1505
|
+
const errorMsg = promptError.data?.message ||
|
|
1506
|
+
promptError.name ||
|
|
1507
|
+
'Unknown provider error';
|
|
1508
|
+
// Retry transient errors (e.g. "Aborted" from config push tearing down the bus)
|
|
1509
|
+
if (this.isRetryablePromptError(errorMsg) && attempt < maxRetries) {
|
|
1510
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
1511
|
+
console.warn(`[OpencodeAdapter] Retryable provider error "${errorMsg}" for ${sessionId}, retrying (${attempt + 1}/${maxRetries}) after ${delay}ms`);
|
|
1512
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
console.error(`[OpencodeAdapter] Provider error for ${sessionId}: ${errorMsg}`);
|
|
1516
|
+
this.promptErrors.set(sessionId, errorMsg);
|
|
1517
|
+
if (this.onDataAvailable) {
|
|
1518
|
+
this.onDataAvailable(sessionId);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// Success or non-retryable error — stop retrying
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
catch (err) {
|
|
1525
|
+
// User-initiated abort — exit silently
|
|
1526
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
1527
|
+
return;
|
|
1528
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1529
|
+
// Retry transient HTTP-level errors
|
|
1530
|
+
if (this.isRetryablePromptError(errorMsg) && attempt < maxRetries) {
|
|
1531
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
1532
|
+
console.warn(`[OpencodeAdapter] Retryable HTTP error "${errorMsg}" for ${sessionId}, retrying (${attempt + 1}/${maxRetries}) after ${delay}ms`);
|
|
1533
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
console.error('[OpencodeAdapter] prompt error:', err);
|
|
1537
|
+
// Surface HTTP-level errors (network failures, 4xx/5xx, connection refused)
|
|
1538
|
+
// via promptErrors so getStatus() returns ERROR instead of silent IDLE.
|
|
1539
|
+
this.promptErrors.set(sessionId, errorMsg);
|
|
1540
|
+
if (this.onDataAvailable) {
|
|
1541
|
+
this.onDataAvailable(sessionId);
|
|
1542
|
+
}
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
async getStatus(sessionId, config) {
|
|
1548
|
+
const ocClient = this.clients.get(sessionId);
|
|
1549
|
+
if (!ocClient) {
|
|
1550
|
+
return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: 'Client not found' };
|
|
1551
|
+
}
|
|
1552
|
+
// Check for pending permissions BEFORE the promptAborts check.
|
|
1553
|
+
// Permissions block tool execution while the prompt HTTP call is still
|
|
1554
|
+
// in-flight. If we don't surface them here, the session appears busy
|
|
1555
|
+
// forever because the prompt never completes.
|
|
1556
|
+
if (this.pendingPermissions.has(sessionId) &&
|
|
1557
|
+
(this.pendingPermissions.get(sessionId)?.length ?? 0) > 0) {
|
|
1558
|
+
return { type: coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL };
|
|
1559
|
+
}
|
|
1560
|
+
// If the session.prompt() HTTP call is still in-flight, the agent is
|
|
1561
|
+
// definitely still working — regardless of what the status API reports.
|
|
1562
|
+
// This prevents premature IDLE detection when the status API briefly
|
|
1563
|
+
// returns idle between tool call rounds or when using models (like
|
|
1564
|
+
// featherless kimi k2.5) whose tool call format may not be fully
|
|
1565
|
+
// reflected in the status endpoint.
|
|
1566
|
+
if (this.promptAborts.has(sessionId)) {
|
|
1567
|
+
return { type: coding_agent_adapter_1.SessionStatusType.BUSY };
|
|
1568
|
+
}
|
|
1569
|
+
const statusResult = await ocClient.session.status({
|
|
1570
|
+
...(config.workspaceDir && { query: { directory: config.workspaceDir } })
|
|
1571
|
+
});
|
|
1572
|
+
if (!statusResult.data) {
|
|
1573
|
+
return this.resolveIdleOrPromptError(sessionId);
|
|
1574
|
+
}
|
|
1575
|
+
const ocStatus = statusResult.data[sessionId];
|
|
1576
|
+
if (!ocStatus) {
|
|
1577
|
+
return this.resolveIdleOrPromptError(sessionId);
|
|
1578
|
+
}
|
|
1579
|
+
const sdkType = (ocStatus.type || 'idle');
|
|
1580
|
+
if (sdkType === 'waiting_approval' ||
|
|
1581
|
+
sdkType === 'waiting_input' ||
|
|
1582
|
+
sdkType === 'waiting_user') {
|
|
1583
|
+
return { type: coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL };
|
|
1584
|
+
}
|
|
1585
|
+
// Check for pending questions via V2 SDK
|
|
1586
|
+
try {
|
|
1587
|
+
const v2 = this.getV2Client(config);
|
|
1588
|
+
const listResult = await v2.question.list({
|
|
1589
|
+
...(config.workspaceDir && { directory: config.workspaceDir })
|
|
1590
|
+
});
|
|
1591
|
+
if (!listResult.error && listResult.data) {
|
|
1592
|
+
const questions = listResult.data;
|
|
1593
|
+
const targetQuestion = questions.find((q) => q.sessionID === sessionId ||
|
|
1594
|
+
q.sessionId === sessionId);
|
|
1595
|
+
if (targetQuestion?.id) {
|
|
1596
|
+
return { type: coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL };
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
catch {
|
|
1601
|
+
// Ignore errors when checking for questions
|
|
1602
|
+
}
|
|
1603
|
+
// If the status API says idle, double-check the messages for tool parts
|
|
1604
|
+
// that are still pending/running. Some models (like featherless kimi k2.5)
|
|
1605
|
+
// may have tool calls in flight that the status API doesn't reflect.
|
|
1606
|
+
if (sdkType === 'idle') {
|
|
1607
|
+
try {
|
|
1608
|
+
const messagesResult = await ocClient.session.messages({
|
|
1609
|
+
path: { id: sessionId },
|
|
1610
|
+
...(config.workspaceDir && {
|
|
1611
|
+
query: { directory: config.workspaceDir }
|
|
1612
|
+
})
|
|
1613
|
+
});
|
|
1614
|
+
if (messagesResult.data && Array.isArray(messagesResult.data)) {
|
|
1615
|
+
// Check the last assistant message for pending tool parts
|
|
1616
|
+
for (let i = messagesResult.data.length - 1; i >= 0; i--) {
|
|
1617
|
+
const msg = messagesResult.data[i];
|
|
1618
|
+
if (!msg.info || msg.info.role === 'user')
|
|
1619
|
+
continue;
|
|
1620
|
+
const parts = msg.parts || [];
|
|
1621
|
+
for (const part of parts) {
|
|
1622
|
+
if (part.type === 'tool') {
|
|
1623
|
+
const state = part.state;
|
|
1624
|
+
const toolStatus = state?.status;
|
|
1625
|
+
if (toolStatus === 'pending' || toolStatus === 'running') {
|
|
1626
|
+
console.log(`[OpencodeAdapter] Status API says idle but tool part ${part.id} is ${toolStatus} — reporting BUSY`);
|
|
1627
|
+
return { type: coding_agent_adapter_1.SessionStatusType.BUSY };
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
// Only check the last assistant message
|
|
1632
|
+
break;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
catch (err) {
|
|
1637
|
+
console.warn('[OpencodeAdapter] Failed to check messages for pending tools:', err);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
const statusType = sdkType.toUpperCase();
|
|
1641
|
+
const resolvedType = coding_agent_adapter_1.SessionStatusType[statusType] ?? coding_agent_adapter_1.SessionStatusType.IDLE;
|
|
1642
|
+
if (resolvedType === coding_agent_adapter_1.SessionStatusType.IDLE) {
|
|
1643
|
+
return this.resolveIdleOrPromptError(sessionId);
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
type: resolvedType,
|
|
1647
|
+
message: 'message' in ocStatus
|
|
1648
|
+
? ocStatus.message
|
|
1649
|
+
: undefined
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Returns ERROR with captured prompt error if one exists, otherwise IDLE.
|
|
1654
|
+
* Called from getStatus when the backend reports no active work.
|
|
1655
|
+
*/
|
|
1656
|
+
resolveIdleOrPromptError(sessionId) {
|
|
1657
|
+
const promptError = this.promptErrors.get(sessionId);
|
|
1658
|
+
if (promptError) {
|
|
1659
|
+
this.promptErrors.delete(sessionId);
|
|
1660
|
+
return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: promptError };
|
|
1661
|
+
}
|
|
1662
|
+
return { type: coding_agent_adapter_1.SessionStatusType.IDLE };
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Transforms a raw OpenCode tool part into the structured format the renderer expects.
|
|
1666
|
+
* OpenCode returns tool name as `part.tool` (string) and details in `part.state`.
|
|
1667
|
+
*/
|
|
1668
|
+
transformToolPart(part) {
|
|
1669
|
+
const state = (part.state || {});
|
|
1670
|
+
const stateInput = (state.input && typeof state.input === 'object' ? state.input : {});
|
|
1671
|
+
const toolName = part.tool || 'unknown';
|
|
1672
|
+
const status = state.status || 'unknown';
|
|
1673
|
+
const inputStr = stateInput && Object.keys(stateInput).length > 0
|
|
1674
|
+
? JSON.stringify(stateInput, null, 2)
|
|
1675
|
+
: undefined;
|
|
1676
|
+
const outputStr = state.output
|
|
1677
|
+
? String(state.output).slice(0, 2000)
|
|
1678
|
+
: undefined;
|
|
1679
|
+
const errorStr = status === 'error' && state.error ? String(state.error) : undefined;
|
|
1680
|
+
// Detect interactive question tools
|
|
1681
|
+
let questions = stateInput.questions;
|
|
1682
|
+
if (typeof questions === 'string') {
|
|
1683
|
+
try {
|
|
1684
|
+
questions = JSON.parse(questions);
|
|
1685
|
+
}
|
|
1686
|
+
catch { }
|
|
1687
|
+
}
|
|
1688
|
+
// Detect TodoWrite tools
|
|
1689
|
+
let todos = stateInput.todos;
|
|
1690
|
+
if (typeof todos === 'string') {
|
|
1691
|
+
try {
|
|
1692
|
+
todos = JSON.parse(todos);
|
|
1693
|
+
}
|
|
1694
|
+
catch { }
|
|
1695
|
+
}
|
|
1696
|
+
let partType = 'tool';
|
|
1697
|
+
if (Array.isArray(questions) && questions.length > 0)
|
|
1698
|
+
partType = 'question';
|
|
1699
|
+
else if (Array.isArray(todos) && todos.length > 0)
|
|
1700
|
+
partType = 'todowrite';
|
|
1701
|
+
return {
|
|
1702
|
+
id: part.id,
|
|
1703
|
+
type: partType,
|
|
1704
|
+
text: part.text,
|
|
1705
|
+
content: part.text,
|
|
1706
|
+
tool: {
|
|
1707
|
+
name: toolName,
|
|
1708
|
+
status,
|
|
1709
|
+
title: state.title || undefined,
|
|
1710
|
+
input: inputStr,
|
|
1711
|
+
output: outputStr,
|
|
1712
|
+
error: errorStr,
|
|
1713
|
+
...(Array.isArray(questions) && questions.length > 0 && { questions }),
|
|
1714
|
+
/*
|
|
1715
|
+
* NORMALISED HERE, as the Claude adapter already does.
|
|
1716
|
+
*
|
|
1717
|
+
* opencode's `todowrite` input carries no `id`, and this forwarded it
|
|
1718
|
+
* raw — so every todo reached a renderer with `id: undefined`. Both
|
|
1719
|
+
* renderers key the list on `todo.id` directly (20x's
|
|
1720
|
+
* `AgentTranscriptPanel` and the workflow-builder `TodoBlock`), so the
|
|
1721
|
+
* list was keyed entirely on `undefined`: React warns, and colliding
|
|
1722
|
+
* keys let it reuse the wrong DOM node, so ticking one item can repaint
|
|
1723
|
+
* a different row.
|
|
1724
|
+
*
|
|
1725
|
+
* THE ADAPTER IS THE RIGHT HOME and the renderer is not. A renderer
|
|
1726
|
+
* that repaired the data would be guessing at what the backend meant,
|
|
1727
|
+
* and there are two of them to keep in step; the adapter is the one
|
|
1728
|
+
* place that knows this backend's shape. `claude-code-adapter` already
|
|
1729
|
+
* normalises exactly here, so this closes a gap between the two rather
|
|
1730
|
+
* than inventing a rule.
|
|
1731
|
+
*/
|
|
1732
|
+
...(Array.isArray(todos) &&
|
|
1733
|
+
todos.length > 0 && { todos: normaliseTodoIds(todos) })
|
|
1734
|
+
},
|
|
1735
|
+
state: part.state
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
async pollMessages(sessionId, seenMessageIds, seenPartIds, partContentLengths, config) {
|
|
1739
|
+
const ocClient = this.clients.get(sessionId);
|
|
1740
|
+
if (!ocClient) {
|
|
1741
|
+
return [];
|
|
1742
|
+
}
|
|
1743
|
+
const messagesResult = await ocClient.session.messages({
|
|
1744
|
+
path: { id: sessionId },
|
|
1745
|
+
...(config.workspaceDir && { query: { directory: config.workspaceDir } })
|
|
1746
|
+
});
|
|
1747
|
+
if (!messagesResult.data || !Array.isArray(messagesResult.data)) {
|
|
1748
|
+
return [];
|
|
1749
|
+
}
|
|
1750
|
+
const newParts = [];
|
|
1751
|
+
for (const msg of messagesResult.data) {
|
|
1752
|
+
if (!msg.info)
|
|
1753
|
+
continue;
|
|
1754
|
+
const msgId = msg.info.id;
|
|
1755
|
+
const msgRole = msg.info.role; // Get role from message
|
|
1756
|
+
// Skip if already seen and no parts have changed
|
|
1757
|
+
const isNewMessage = !seenMessageIds.has(msgId);
|
|
1758
|
+
if (isNewMessage) {
|
|
1759
|
+
seenMessageIds.add(msgId);
|
|
1760
|
+
}
|
|
1761
|
+
const parts = msg.parts && Array.isArray(msg.parts) ? msg.parts : [];
|
|
1762
|
+
for (const [partIndex, part] of parts.entries()) {
|
|
1763
|
+
const partId = this.getScopedPartId(String(msgId), part.id, partIndex);
|
|
1764
|
+
if (!partId)
|
|
1765
|
+
continue;
|
|
1766
|
+
// Cast part to a loose record for uniform property access across SDK Part union members
|
|
1767
|
+
const p = part;
|
|
1768
|
+
const isNewPart = !seenPartIds.has(partId);
|
|
1769
|
+
const isUpdatable = part.type === 'text' ||
|
|
1770
|
+
part.type === 'reasoning' ||
|
|
1771
|
+
part.type === 'tool';
|
|
1772
|
+
if (isUpdatable) {
|
|
1773
|
+
const fingerprint = part.type === 'tool'
|
|
1774
|
+
? `${p.state?.status}:${part.type}:${p.text?.length ?? 0}:${p.state?.output?.length ?? 0}`
|
|
1775
|
+
: String(p.text?.length ?? 0);
|
|
1776
|
+
const oldFingerprint = partContentLengths.get(partId);
|
|
1777
|
+
const hasChanged = oldFingerprint !== fingerprint;
|
|
1778
|
+
if (isNewPart || hasChanged) {
|
|
1779
|
+
seenPartIds.add(partId);
|
|
1780
|
+
partContentLengths.set(partId, fingerprint);
|
|
1781
|
+
if (part.type === 'tool') {
|
|
1782
|
+
const transformed = this.transformToolPart(p);
|
|
1783
|
+
newParts.push({
|
|
1784
|
+
...transformed,
|
|
1785
|
+
id: partId,
|
|
1786
|
+
role: msgRole,
|
|
1787
|
+
update: !isNewPart
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
else {
|
|
1791
|
+
newParts.push({
|
|
1792
|
+
id: partId,
|
|
1793
|
+
type: part.type,
|
|
1794
|
+
text: p.text,
|
|
1795
|
+
content: p.text,
|
|
1796
|
+
role: msgRole,
|
|
1797
|
+
update: !isNewPart
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
else if (isNewPart) {
|
|
1803
|
+
seenPartIds.add(partId);
|
|
1804
|
+
newParts.push({
|
|
1805
|
+
id: partId,
|
|
1806
|
+
type: part.type,
|
|
1807
|
+
text: p.text,
|
|
1808
|
+
content: p.text,
|
|
1809
|
+
role: msgRole // Include role from message
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return newParts;
|
|
1815
|
+
}
|
|
1816
|
+
async getRunningTools(sessionId, config) {
|
|
1817
|
+
const ocClient = this.clients.get(sessionId);
|
|
1818
|
+
if (!ocClient)
|
|
1819
|
+
return [];
|
|
1820
|
+
try {
|
|
1821
|
+
const messagesResult = await ocClient.session.messages({
|
|
1822
|
+
path: { id: sessionId },
|
|
1823
|
+
...(config.workspaceDir && {
|
|
1824
|
+
query: { directory: config.workspaceDir }
|
|
1825
|
+
})
|
|
1826
|
+
});
|
|
1827
|
+
if (!messagesResult.data || !Array.isArray(messagesResult.data))
|
|
1828
|
+
return [];
|
|
1829
|
+
const running = [];
|
|
1830
|
+
for (const msg of messagesResult.data) {
|
|
1831
|
+
const parts = msg.parts;
|
|
1832
|
+
if (!parts || !Array.isArray(parts))
|
|
1833
|
+
continue;
|
|
1834
|
+
for (const part of parts) {
|
|
1835
|
+
if (part.type !== 'tool')
|
|
1836
|
+
continue;
|
|
1837
|
+
const state = part.state;
|
|
1838
|
+
if (!state || state.status !== 'running')
|
|
1839
|
+
continue;
|
|
1840
|
+
const timeObj = state.time;
|
|
1841
|
+
const input = state.input;
|
|
1842
|
+
running.push({
|
|
1843
|
+
partId: part.id,
|
|
1844
|
+
toolName: part.tool || 'unknown',
|
|
1845
|
+
startTime: timeObj?.start,
|
|
1846
|
+
input: input || undefined
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
return running;
|
|
1851
|
+
}
|
|
1852
|
+
catch (err) {
|
|
1853
|
+
console.warn(`[OpencodeAdapter] getRunningTools failed for ${sessionId}:`, err instanceof Error ? err.message : err);
|
|
1854
|
+
return [];
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
async abortPrompt(sessionId, _config) {
|
|
1858
|
+
const abort = this.promptAborts.get(sessionId);
|
|
1859
|
+
if (abort) {
|
|
1860
|
+
abort.abort();
|
|
1861
|
+
this.promptAborts.delete(sessionId);
|
|
1862
|
+
}
|
|
1863
|
+
// Also abort the session on the server side to ensure the backend
|
|
1864
|
+
// stops processing. Without this, the backend continues running the
|
|
1865
|
+
// old prompt (model generating tokens, bash commands executing) and
|
|
1866
|
+
// rejects or queues new prompts — so the user can't recover by
|
|
1867
|
+
// sending a follow-up message.
|
|
1868
|
+
const ocClient = this.clients.get(sessionId);
|
|
1869
|
+
if (ocClient) {
|
|
1870
|
+
try {
|
|
1871
|
+
await ocClient.session.abort({
|
|
1872
|
+
path: { id: sessionId },
|
|
1873
|
+
...(_config.workspaceDir && {
|
|
1874
|
+
query: { directory: _config.workspaceDir }
|
|
1875
|
+
})
|
|
1876
|
+
});
|
|
1877
|
+
console.log(`[OpencodeAdapter] Server-side abort sent for session ${sessionId}`);
|
|
1878
|
+
}
|
|
1879
|
+
catch (err) {
|
|
1880
|
+
// Non-fatal: the local abort is sufficient for the HTTP request.
|
|
1881
|
+
// Server-side abort can fail if session is already idle or not found.
|
|
1882
|
+
console.warn(`[OpencodeAdapter] Server-side abort failed for ${sessionId}:`, err instanceof Error ? err.message : err);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
async destroySession(sessionId, _config) {
|
|
1887
|
+
await this.abortPrompt(sessionId, _config);
|
|
1888
|
+
// Release the stdio MCP children of this session before dropping the client.
|
|
1889
|
+
// They are children of the long-lived `opencode serve` process, so nothing
|
|
1890
|
+
// else would ever stop them.
|
|
1891
|
+
await this.disconnectSessionMcpServers(sessionId);
|
|
1892
|
+
this.clients.delete(sessionId);
|
|
1893
|
+
this.promptClients.delete(sessionId);
|
|
1894
|
+
this.pendingPermissions.delete(sessionId);
|
|
1895
|
+
this.sessionPermissionModes.delete(sessionId);
|
|
1896
|
+
this.sessionWorkspaceDirs.delete(sessionId);
|
|
1897
|
+
this.sessionMcpConfigs.delete(sessionId);
|
|
1898
|
+
this.sessionMcpAttachFailures.delete(sessionId);
|
|
1899
|
+
this.removeTillDoneSessionConfig(sessionId);
|
|
1900
|
+
if (this.clients.size > 0) {
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
// Clean up generated runtime plugins and any support files they use.
|
|
1904
|
+
for (const filePath of [
|
|
1905
|
+
...this.pluginFilePaths,
|
|
1906
|
+
...this.runtimeSupportFilePaths
|
|
1907
|
+
]) {
|
|
1908
|
+
if (filePath) {
|
|
1909
|
+
try {
|
|
1910
|
+
if ((0, fs_1.existsSync)(filePath)) {
|
|
1911
|
+
(0, fs_1.rmSync)(filePath);
|
|
1912
|
+
console.log(`[OpencodeAdapter] Removed runtime plugin file: ${filePath}`);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
catch (err) {
|
|
1916
|
+
console.warn(`[OpencodeAdapter] Failed to remove runtime plugin file: ${err}`);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
this.pluginFilePaths = [];
|
|
1921
|
+
this.runtimeSupportFilePaths = [];
|
|
1922
|
+
this.tillDoneConfigPath = null;
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Writes all generated runtime plugin files needed for this session.
|
|
1926
|
+
* MUST be called BEFORE ensureServerRunning() so plugins are discovered at startup.
|
|
1927
|
+
*/
|
|
1928
|
+
writeRuntimePluginFiles(config) {
|
|
1929
|
+
this.pluginFilePaths = [];
|
|
1930
|
+
this.runtimeSupportFilePaths = [];
|
|
1931
|
+
this.tillDoneConfigPath = null;
|
|
1932
|
+
if (!config.workspaceDir) {
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const openCodeDir = (0, path_1.join)(config.workspaceDir, '.opencode');
|
|
1936
|
+
const pluginsDir = (0, path_1.join)(openCodeDir, 'plugins');
|
|
1937
|
+
(0, fs_1.mkdirSync)(pluginsDir, { recursive: true });
|
|
1938
|
+
this.writeTillDonePlugin(openCodeDir, pluginsDir, config.tillDone !== false);
|
|
1939
|
+
this.writeSecretPlugin(config, openCodeDir, pluginsDir);
|
|
1940
|
+
}
|
|
1941
|
+
writeSecretPlugin(config, openCodeDir, pluginsDir) {
|
|
1942
|
+
const secretCount = config.secretEnvVars
|
|
1943
|
+
? Object.keys(config.secretEnvVars).length
|
|
1944
|
+
: 0;
|
|
1945
|
+
console.log(`[OpencodeAdapter] writeSecretFiles: workspaceDir=${config.workspaceDir}, secretCount=${secretCount}`);
|
|
1946
|
+
if (!config.secretEnvVars || secretCount === 0) {
|
|
1947
|
+
console.log(`[OpencodeAdapter] writeSecretFiles: skipping — no secrets or no workspaceDir`);
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
// 1. Write pre-formatted export commands to a secrets file.
|
|
1951
|
+
// The plugin reads this file on every bash invocation so updates take effect immediately.
|
|
1952
|
+
const exportLines = Object.entries(config.secretEnvVars)
|
|
1953
|
+
.map(([k, v]) => 'export ' + k + "='" + v.replace(/'/g, "'\\''") + "'")
|
|
1954
|
+
.join('\n');
|
|
1955
|
+
const secretsPath = (0, path_1.join)(openCodeDir, '.20x-secrets');
|
|
1956
|
+
(0, fs_1.writeFileSync)(secretsPath, exportLines, 'utf-8');
|
|
1957
|
+
this.runtimeSupportFilePaths.push(secretsPath);
|
|
1958
|
+
// 2. Write the plugin JS that reads the secrets file and prepends exports to bash commands.
|
|
1959
|
+
// Uses tool.execute.before hook (same pattern as Claude Code PreToolUse).
|
|
1960
|
+
const pluginPath = (0, path_1.join)(pluginsDir, '20x-secret-injector.js');
|
|
1961
|
+
const pluginCode = this.buildSecretInjectorPluginCode(secretsPath);
|
|
1962
|
+
(0, fs_1.writeFileSync)(pluginPath, pluginCode, 'utf-8');
|
|
1963
|
+
this.pluginFilePaths.push(pluginPath);
|
|
1964
|
+
console.log(`[OpencodeAdapter] Wrote secret files: plugin=${pluginPath}, secrets=${secretsPath} (${Object.keys(config.secretEnvVars).length} secret(s))`);
|
|
1965
|
+
}
|
|
1966
|
+
writeTillDonePlugin(openCodeDir, pluginsDir, enabled) {
|
|
1967
|
+
const statePath = (0, path_1.join)(openCodeDir, '.20x-tilldone-state.json');
|
|
1968
|
+
if (!(0, fs_1.existsSync)(statePath)) {
|
|
1969
|
+
(0, fs_1.writeFileSync)(statePath, '{}\n', 'utf-8');
|
|
1970
|
+
}
|
|
1971
|
+
this.runtimeSupportFilePaths.push(statePath);
|
|
1972
|
+
const configPath = (0, path_1.join)(openCodeDir, '.20x-tilldone-config.json');
|
|
1973
|
+
const existingConfig = this.readTillDoneConfigFile(configPath);
|
|
1974
|
+
(0, fs_1.writeFileSync)(configPath, JSON.stringify({
|
|
1975
|
+
defaultEnabled: enabled,
|
|
1976
|
+
sessions: existingConfig.sessions
|
|
1977
|
+
}), 'utf-8');
|
|
1978
|
+
this.runtimeSupportFilePaths.push(configPath);
|
|
1979
|
+
this.tillDoneConfigPath = configPath;
|
|
1980
|
+
const pluginPath = (0, path_1.join)(pluginsDir, '20x-tilldone.js');
|
|
1981
|
+
const pluginCode = this.buildTillDonePluginCode(statePath, configPath);
|
|
1982
|
+
(0, fs_1.writeFileSync)(pluginPath, pluginCode, 'utf-8');
|
|
1983
|
+
this.pluginFilePaths.push(pluginPath);
|
|
1984
|
+
console.log(`[OpencodeAdapter] Wrote tilldone plugin: plugin=${pluginPath}, state=${statePath}, enabled=${enabled}`);
|
|
1985
|
+
}
|
|
1986
|
+
readTillDoneConfig() {
|
|
1987
|
+
if (!this.tillDoneConfigPath) {
|
|
1988
|
+
return { defaultEnabled: true, sessions: {} };
|
|
1989
|
+
}
|
|
1990
|
+
return this.readTillDoneConfigFile(this.tillDoneConfigPath);
|
|
1991
|
+
}
|
|
1992
|
+
readTillDoneConfigFile(configPath) {
|
|
1993
|
+
if (!(0, fs_1.existsSync)(configPath)) {
|
|
1994
|
+
return { defaultEnabled: true, sessions: {} };
|
|
1995
|
+
}
|
|
1996
|
+
try {
|
|
1997
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf-8') || '{}');
|
|
1998
|
+
const sessions = parsed.sessions &&
|
|
1999
|
+
typeof parsed.sessions === 'object' &&
|
|
2000
|
+
!Array.isArray(parsed.sessions)
|
|
2001
|
+
? Object.fromEntries(Object.entries(parsed.sessions).filter(([, value]) => typeof value === 'boolean'))
|
|
2002
|
+
: {};
|
|
2003
|
+
return {
|
|
2004
|
+
defaultEnabled: parsed.defaultEnabled !== undefined
|
|
2005
|
+
? parsed.defaultEnabled !== false
|
|
2006
|
+
: parsed.enabled !== false,
|
|
2007
|
+
sessions
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
catch {
|
|
2011
|
+
return { defaultEnabled: true, sessions: {} };
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
writeTillDoneConfig(config) {
|
|
2015
|
+
if (!this.tillDoneConfigPath)
|
|
2016
|
+
return;
|
|
2017
|
+
(0, fs_1.writeFileSync)(this.tillDoneConfigPath, JSON.stringify(config), 'utf-8');
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* TELL OPENCODE TO ASK — defect G1's fifth wire, and the one that decides
|
|
2021
|
+
* whether the other four are observable at all on this backend.
|
|
2022
|
+
*
|
|
2023
|
+
* THE PROBLEM. This adapter REACTS to `permission.asked`: it queues the
|
|
2024
|
+
* permission, reports WAITING_APPROVAL, and offers getPendingApproval /
|
|
2025
|
+
* respondToApproval. Nothing ever asked OpenCode to EMIT one. Only
|
|
2026
|
+
* `external_directory` defaults to "ask" — `bash`, `edit` and `webfetch`
|
|
2027
|
+
* default to allow — so the entire approval path was unreachable for every
|
|
2028
|
+
* action a user would actually want to approve.
|
|
2029
|
+
*
|
|
2030
|
+
* MEASURED against a real `opencode serve` 1.18.25, real model, real `ls -a`:
|
|
2031
|
+
* no `permission` in the effective config -> permission.asked x0
|
|
2032
|
+
* `permission: {bash|edit|webfetch:'ask'}` -> permission.asked x1, shaped
|
|
2033
|
+
* { id, sessionID, permission:'bash', patterns:['ls -a'], ... },
|
|
2034
|
+
* exactly what `handleServerEvent` already parses.
|
|
2035
|
+
*
|
|
2036
|
+
* WHY A FILE IN THE SESSION'S WORKSPACE, AND NOT THE MERGED SERVER CONFIG.
|
|
2037
|
+
*
|
|
2038
|
+
* The obvious place is `buildMergedOpencodeConfig`, and it is WRONG in two
|
|
2039
|
+
* ways that only showed up by running it:
|
|
2040
|
+
*
|
|
2041
|
+
* 1. IT IS SERVER-WIDE. One OpenCode server serves every session on the
|
|
2042
|
+
* host — including, on a desktop, sessions this harness did not create.
|
|
2043
|
+
* A verification run that set it there immediately began raising
|
|
2044
|
+
* permission prompts for unrelated agent sessions belonging to the
|
|
2045
|
+
* machine's owner.
|
|
2046
|
+
* 2. IT PERSISTS. `client.global.config.update` WRITES THROUGH to
|
|
2047
|
+
* `~/.config/opencode/opencode.json`. Setting a permission policy there
|
|
2048
|
+
* permanently rewrites the user's own OpenCode configuration — a file
|
|
2049
|
+
* this product does not own.
|
|
2050
|
+
*
|
|
2051
|
+
* A per-directory `opencode.json` has neither problem, and OpenCode really
|
|
2052
|
+
* does honour it per session: with one shared server, `GET /config` returned
|
|
2053
|
+
* `null` for the server's own cwd and `{bash:'ask',...}` for a session
|
|
2054
|
+
* workspace containing this file. Verified before relying on it.
|
|
2055
|
+
*
|
|
2056
|
+
* WHY IT IS DERIVED FROM THE SESSION'S OWN MODE. Because it can be, now that
|
|
2057
|
+
* it is per-session. A `bypassPermissions` agent writes `allow` and never
|
|
2058
|
+
* pays a round trip; an asking agent writes `ask` and its permissions reach a
|
|
2059
|
+
* human. One agent's choice cannot affect another's.
|
|
2060
|
+
*
|
|
2061
|
+
* THE MERGE IS DELIBERATELY ONE-WAY. Any `opencode.json` already in the
|
|
2062
|
+
* workspace keeps its other keys, but NOT its permission block: a file in the
|
|
2063
|
+
* workspace is attacker-influenceable — the agent can write there — so
|
|
2064
|
+
* letting it weaken this would be defect G10's shape, an access control
|
|
2065
|
+
* bypassable by a file on disk.
|
|
2066
|
+
*/
|
|
2067
|
+
writeSessionPermissionConfig(workspaceDir, permissionMode) {
|
|
2068
|
+
if (!workspaceDir)
|
|
2069
|
+
return;
|
|
2070
|
+
const decision = permissionMode === 'allow' ? 'allow' : 'ask';
|
|
2071
|
+
const configPath = (0, path_1.join)(workspaceDir, 'opencode.json');
|
|
2072
|
+
let existing = {};
|
|
2073
|
+
try {
|
|
2074
|
+
if ((0, fs_1.existsSync)(configPath)) {
|
|
2075
|
+
existing = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf-8'));
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
// A workspace config we cannot parse is replaced rather than merged.
|
|
2080
|
+
// Failing open here would mean no permission block at all, which is the
|
|
2081
|
+
// defect this method exists to close.
|
|
2082
|
+
existing = {};
|
|
2083
|
+
}
|
|
2084
|
+
const merged = {
|
|
2085
|
+
$schema: 'https://opencode.ai/config.json',
|
|
2086
|
+
...existing,
|
|
2087
|
+
// LAST, so the spread above can never weaken it.
|
|
2088
|
+
permission: {
|
|
2089
|
+
...(existing.permission ?? {}),
|
|
2090
|
+
bash: decision,
|
|
2091
|
+
edit: decision,
|
|
2092
|
+
webfetch: decision
|
|
2093
|
+
}
|
|
2094
|
+
};
|
|
2095
|
+
try {
|
|
2096
|
+
(0, fs_1.mkdirSync)(workspaceDir, { recursive: true });
|
|
2097
|
+
(0, fs_1.writeFileSync)(configPath, JSON.stringify(merged, null, 2), 'utf-8');
|
|
2098
|
+
console.log(`[OpencodeAdapter] Wrote session permission config: ${configPath} (bash/edit/webfetch=${decision})`);
|
|
2099
|
+
}
|
|
2100
|
+
catch (err) {
|
|
2101
|
+
// Loud, because a silent failure here is exactly defect G1 again: the
|
|
2102
|
+
// agent would run unrestricted while the UI showed the user their mode.
|
|
2103
|
+
console.error(`[OpencodeAdapter] FAILED to write session permission config at ${configPath}. ` +
|
|
2104
|
+
`Permissions for this session will NOT be enforced:`, err);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
writeTillDoneSessionConfig(sessionId, enabled) {
|
|
2108
|
+
const config = this.readTillDoneConfig();
|
|
2109
|
+
this.writeTillDoneConfig({
|
|
2110
|
+
...config,
|
|
2111
|
+
sessions: {
|
|
2112
|
+
...config.sessions,
|
|
2113
|
+
[sessionId]: enabled
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
removeTillDoneSessionConfig(sessionId) {
|
|
2118
|
+
if (!this.tillDoneConfigPath || !(0, fs_1.existsSync)(this.tillDoneConfigPath))
|
|
2119
|
+
return;
|
|
2120
|
+
const config = this.readTillDoneConfig();
|
|
2121
|
+
if (!(sessionId in config.sessions))
|
|
2122
|
+
return;
|
|
2123
|
+
const sessions = { ...config.sessions };
|
|
2124
|
+
delete sessions[sessionId];
|
|
2125
|
+
this.writeTillDoneConfig({ ...config, sessions });
|
|
2126
|
+
}
|
|
2127
|
+
buildSecretInjectorPluginCode(secretsPath) {
|
|
2128
|
+
return [
|
|
2129
|
+
'// Auto-generated by 20x — do not edit. Removed on session destroy.',
|
|
2130
|
+
'import { readFileSync } from "fs";',
|
|
2131
|
+
'',
|
|
2132
|
+
'var SECRETS_PATH = ' + JSON.stringify(secretsPath) + ';',
|
|
2133
|
+
'',
|
|
2134
|
+
'export var SecretInjector = async function() {',
|
|
2135
|
+
' return {',
|
|
2136
|
+
' "tool.execute.before": async function(input, output) {',
|
|
2137
|
+
' if (input.tool === "bash") {',
|
|
2138
|
+
' try {',
|
|
2139
|
+
' var exports = readFileSync(SECRETS_PATH, "utf-8").trim();',
|
|
2140
|
+
' if (exports) output.args.command = exports + "\\n" + output.args.command;',
|
|
2141
|
+
' } catch(e) {}',
|
|
2142
|
+
' }',
|
|
2143
|
+
' }',
|
|
2144
|
+
' };',
|
|
2145
|
+
'};',
|
|
2146
|
+
''
|
|
2147
|
+
].join('\n');
|
|
2148
|
+
}
|
|
2149
|
+
buildTillDonePluginCode(statePath, configPath) {
|
|
2150
|
+
const initialTodoPrompt = 'TillDone: before using other tools, call the built-in todowrite tool to create a concise task list for this task. Keep the list current as you work.';
|
|
2151
|
+
return [
|
|
2152
|
+
'// Auto-generated by 20x — do not edit. Removed on session destroy.',
|
|
2153
|
+
'import { readFileSync, writeFileSync } from "fs";',
|
|
2154
|
+
'',
|
|
2155
|
+
'var STATE_PATH = ' + JSON.stringify(statePath) + ';',
|
|
2156
|
+
'var CONFIG_PATH = ' + JSON.stringify(configPath) + ';',
|
|
2157
|
+
'var INITIAL_TODO_PROMPT = ' + JSON.stringify(initialTodoPrompt) + ';',
|
|
2158
|
+
'',
|
|
2159
|
+
'function isTillDoneEnabled(sessionId) {',
|
|
2160
|
+
' if (!sessionId) return false;',
|
|
2161
|
+
' try {',
|
|
2162
|
+
' var raw = readFileSync(CONFIG_PATH, "utf-8");',
|
|
2163
|
+
' var parsed = JSON.parse(raw || "{}");',
|
|
2164
|
+
' if (parsed?.sessions && typeof parsed.sessions === "object") {',
|
|
2165
|
+
' if (Object.prototype.hasOwnProperty.call(parsed.sessions, sessionId)) {',
|
|
2166
|
+
' return parsed.sessions[sessionId] !== false;',
|
|
2167
|
+
' }',
|
|
2168
|
+
' return false;',
|
|
2169
|
+
' }',
|
|
2170
|
+
' if (Object.prototype.hasOwnProperty.call(parsed || {}, "defaultEnabled")) {',
|
|
2171
|
+
' return parsed.defaultEnabled !== false;',
|
|
2172
|
+
' }',
|
|
2173
|
+
' return parsed?.enabled !== false;',
|
|
2174
|
+
' } catch (e) {',
|
|
2175
|
+
' return false;',
|
|
2176
|
+
' }',
|
|
2177
|
+
'}',
|
|
2178
|
+
'',
|
|
2179
|
+
'function readState() {',
|
|
2180
|
+
' try {',
|
|
2181
|
+
' var raw = readFileSync(STATE_PATH, "utf-8");',
|
|
2182
|
+
' var parsed = JSON.parse(raw || "{}");',
|
|
2183
|
+
' return parsed && typeof parsed === "object" ? parsed : {};',
|
|
2184
|
+
' } catch (e) {',
|
|
2185
|
+
' return {};',
|
|
2186
|
+
' }',
|
|
2187
|
+
'}',
|
|
2188
|
+
'',
|
|
2189
|
+
'function writeState(state) {',
|
|
2190
|
+
' try {',
|
|
2191
|
+
' writeFileSync(STATE_PATH, JSON.stringify(state), "utf-8");',
|
|
2192
|
+
' } catch (e) {}',
|
|
2193
|
+
' return state;',
|
|
2194
|
+
'}',
|
|
2195
|
+
'',
|
|
2196
|
+
'// SDK types: tool.execute.before input has { tool, sessionID, callID }',
|
|
2197
|
+
'// SDK types: event hook input has { event: { type, properties: { sessionID, ... } } }',
|
|
2198
|
+
'',
|
|
2199
|
+
'function normalizeTodos(rawTodos) {',
|
|
2200
|
+
' if (!Array.isArray(rawTodos)) return [];',
|
|
2201
|
+
' return rawTodos.filter(Boolean).map(function(todo, index) {',
|
|
2202
|
+
' return {',
|
|
2203
|
+
' id: todo.id || String(index),',
|
|
2204
|
+
' content: todo.content || todo.text || todo.title || "",',
|
|
2205
|
+
' status: todo.status || "pending"',
|
|
2206
|
+
' };',
|
|
2207
|
+
' });',
|
|
2208
|
+
'}',
|
|
2209
|
+
'',
|
|
2210
|
+
'export var TillDone = async function({ client }) {',
|
|
2211
|
+
' return {',
|
|
2212
|
+
' "tool.execute.before": async function(input) {',
|
|
2213
|
+
' if (input.tool === "todowrite" || input.tool === "skill" || input.tool === "task") return;',
|
|
2214
|
+
' var sessionId = input.sessionID;',
|
|
2215
|
+
' if (!sessionId) return;',
|
|
2216
|
+
' if (!isTillDoneEnabled(sessionId)) return;',
|
|
2217
|
+
' var state = readState();',
|
|
2218
|
+
' var todos = normalizeTodos((state[sessionId] || {}).todos);',
|
|
2219
|
+
' if (todos.length === 0) {',
|
|
2220
|
+
' throw new Error(INITIAL_TODO_PROMPT);',
|
|
2221
|
+
' }',
|
|
2222
|
+
' },',
|
|
2223
|
+
' event: async function(input) {',
|
|
2224
|
+
' var ev = input.event;',
|
|
2225
|
+
' if (!ev) return;',
|
|
2226
|
+
' var sessionId = ev.properties?.sessionID;',
|
|
2227
|
+
' if (!sessionId) return;',
|
|
2228
|
+
' if (!isTillDoneEnabled(sessionId)) return;',
|
|
2229
|
+
' var state = readState();',
|
|
2230
|
+
' var sessionState = state[sessionId] || { todos: [] };',
|
|
2231
|
+
'',
|
|
2232
|
+
' if (ev.type === "todo.updated") {',
|
|
2233
|
+
' sessionState.todos = normalizeTodos(ev.properties?.todos || []);',
|
|
2234
|
+
' state[sessionId] = sessionState;',
|
|
2235
|
+
' writeState(state);',
|
|
2236
|
+
' return;',
|
|
2237
|
+
' }',
|
|
2238
|
+
'',
|
|
2239
|
+
' if (ev.type === "session.deleted") {',
|
|
2240
|
+
' delete state[sessionId];',
|
|
2241
|
+
' writeState(state);',
|
|
2242
|
+
' return;',
|
|
2243
|
+
' }',
|
|
2244
|
+
' }',
|
|
2245
|
+
' };',
|
|
2246
|
+
'};',
|
|
2247
|
+
''
|
|
2248
|
+
].join('\n');
|
|
2249
|
+
}
|
|
2250
|
+
async getAllMessages(sessionId, config) {
|
|
2251
|
+
const ocClient = this.clients.get(sessionId);
|
|
2252
|
+
if (!ocClient) {
|
|
2253
|
+
return [];
|
|
2254
|
+
}
|
|
2255
|
+
try {
|
|
2256
|
+
// Fetch all messages from OpenCode API
|
|
2257
|
+
const messagesResult = await ocClient.session.messages({
|
|
2258
|
+
path: { id: sessionId },
|
|
2259
|
+
...(config.workspaceDir && {
|
|
2260
|
+
query: { directory: config.workspaceDir }
|
|
2261
|
+
})
|
|
2262
|
+
});
|
|
2263
|
+
if (!messagesResult.data || !Array.isArray(messagesResult.data)) {
|
|
2264
|
+
return [];
|
|
2265
|
+
}
|
|
2266
|
+
// Convert OpenCode messages to SessionMessage format
|
|
2267
|
+
const messages = messagesResult.data.map((msg, idx) => {
|
|
2268
|
+
const msgInfo = msg.info;
|
|
2269
|
+
const role = msgInfo?.role || 'assistant';
|
|
2270
|
+
const parts = (msg.parts || []);
|
|
2271
|
+
return {
|
|
2272
|
+
id: msgInfo?.id || `msg-${idx}`,
|
|
2273
|
+
role: (role === 'user'
|
|
2274
|
+
? coding_agent_adapter_1.MessageRole.USER
|
|
2275
|
+
: coding_agent_adapter_1.MessageRole.ASSISTANT),
|
|
2276
|
+
parts: parts.map((p, partIndex) => ({
|
|
2277
|
+
id: this.getScopedPartId(String(msgInfo?.id || `msg-${idx}`), p.id, partIndex),
|
|
2278
|
+
type: p.type,
|
|
2279
|
+
text: p.text,
|
|
2280
|
+
content: p.text
|
|
2281
|
+
}))
|
|
2282
|
+
};
|
|
2283
|
+
});
|
|
2284
|
+
return messages;
|
|
2285
|
+
}
|
|
2286
|
+
catch (error) {
|
|
2287
|
+
console.error('[OpencodeAdapter] Error fetching messages:', error);
|
|
2288
|
+
return [];
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
async registerMcpServer(_serverName, _mcpConfig, _workspaceDir) {
|
|
2292
|
+
// This needs to be called before session creation
|
|
2293
|
+
// We need access to the ocClient, which we don't have yet
|
|
2294
|
+
// For now, this will be handled in the AgentManager until we refactor further
|
|
2295
|
+
throw new Error('registerMcpServer must be called via AgentManager for now');
|
|
2296
|
+
}
|
|
2297
|
+
getV2Client(config) {
|
|
2298
|
+
if (this.v2Client)
|
|
2299
|
+
return this.v2Client;
|
|
2300
|
+
if (!OpenCodeV2Client)
|
|
2301
|
+
throw new Error('OpenCode V2 SDK not loaded');
|
|
2302
|
+
const baseUrl = this.serverUrl || config.serverUrl || DEFAULT_SERVER_URL;
|
|
2303
|
+
this.v2Client = OpenCodeV2Client.createOpencodeClient({ baseUrl });
|
|
2304
|
+
return this.v2Client;
|
|
2305
|
+
}
|
|
2306
|
+
async respondToQuestion(sessionId, answers, config) {
|
|
2307
|
+
const v2 = this.getV2Client(config);
|
|
2308
|
+
try {
|
|
2309
|
+
// List pending questions via V2 SDK
|
|
2310
|
+
const listResult = await v2.question.list({
|
|
2311
|
+
...(config.workspaceDir && { directory: config.workspaceDir })
|
|
2312
|
+
});
|
|
2313
|
+
if (listResult.error) {
|
|
2314
|
+
throw new Error(`question.list failed: ${JSON.stringify(listResult.error)}`);
|
|
2315
|
+
}
|
|
2316
|
+
const questions = listResult.data ?? [];
|
|
2317
|
+
console.log(`[OpencodeAdapter] Pending questions (${questions.length}):`, questions.map((q) => ({
|
|
2318
|
+
id: q.id,
|
|
2319
|
+
sessionID: q.sessionID,
|
|
2320
|
+
sessionId: q.sessionId,
|
|
2321
|
+
questionCount: q.questions?.length
|
|
2322
|
+
})));
|
|
2323
|
+
// Find the question for this session
|
|
2324
|
+
const question = questions.find((q) => q.sessionID === sessionId ||
|
|
2325
|
+
q.sessionId === sessionId);
|
|
2326
|
+
if (!question?.id) {
|
|
2327
|
+
throw new Error(`[OpencodeAdapter] No pending question found for session ${sessionId}`);
|
|
2328
|
+
}
|
|
2329
|
+
console.log(`[OpencodeAdapter] Matched question:`, JSON.stringify(question, null, 2).slice(0, 1000));
|
|
2330
|
+
// Build answers aligned with the question's questions array order
|
|
2331
|
+
const questionItems = question.questions ?? [];
|
|
2332
|
+
const answerKeys = Object.keys(answers);
|
|
2333
|
+
const formattedAnswers = [];
|
|
2334
|
+
for (let i = 0; i < questionItems.length; i++) {
|
|
2335
|
+
const qItem = questionItems[i];
|
|
2336
|
+
const matchKey = answerKeys.find((k) => k === qItem.header || k === qItem.question);
|
|
2337
|
+
const answerValue = matchKey
|
|
2338
|
+
? answers[matchKey]
|
|
2339
|
+
: Object.values(answers)[i];
|
|
2340
|
+
formattedAnswers.push(answerValue ? [answerValue] : []);
|
|
2341
|
+
}
|
|
2342
|
+
console.log(`[OpencodeAdapter] Replying to question ${question.id} (${questionItems.length} items) with:`, formattedAnswers);
|
|
2343
|
+
// Reply via V2 SDK
|
|
2344
|
+
const replyResult = await v2.question.reply({
|
|
2345
|
+
requestID: question.id,
|
|
2346
|
+
answers: formattedAnswers,
|
|
2347
|
+
...(config.workspaceDir && { directory: config.workspaceDir })
|
|
2348
|
+
});
|
|
2349
|
+
if (replyResult.error) {
|
|
2350
|
+
throw new Error(`question.reply failed: ${JSON.stringify(replyResult.error)}`);
|
|
2351
|
+
}
|
|
2352
|
+
console.log(`[OpencodeAdapter] Question ${question.id} replied successfully`);
|
|
2353
|
+
}
|
|
2354
|
+
catch (err) {
|
|
2355
|
+
console.error('[OpencodeAdapter] Question API failed:', err);
|
|
2356
|
+
throw err;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
// ========================================================================
|
|
2360
|
+
// Permission handling — OpenCode file-permission prompts surfaced in 20x UI
|
|
2361
|
+
// ========================================================================
|
|
2362
|
+
/**
|
|
2363
|
+
* Returns the first pending permission for a session, formatted as an
|
|
2364
|
+
* approval request the agent-manager can render. The agent-manager
|
|
2365
|
+
* duck-types for this method on any adapter during polling.
|
|
2366
|
+
*/
|
|
2367
|
+
getPendingApproval(sessionId) {
|
|
2368
|
+
const queue = this.pendingPermissions.get(sessionId);
|
|
2369
|
+
if (!queue || queue.length === 0)
|
|
2370
|
+
return null;
|
|
2371
|
+
const pending = queue[0];
|
|
2372
|
+
const pathList = pending.patterns.length > 0
|
|
2373
|
+
? pending.patterns.join(', ')
|
|
2374
|
+
: 'requested path';
|
|
2375
|
+
return {
|
|
2376
|
+
toolCallId: pending.permissionId,
|
|
2377
|
+
question: `Allow ${pending.permission} access to: ${pathList}`,
|
|
2378
|
+
options: [
|
|
2379
|
+
{ optionId: 'allow', name: 'Yes', kind: 'option' },
|
|
2380
|
+
{ optionId: 'allow-always', name: 'Always', kind: 'option' },
|
|
2381
|
+
{ optionId: 'deny', name: 'No', kind: 'option' }
|
|
2382
|
+
]
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Responds to a pending permission via the OpenCode HTTP API.
|
|
2387
|
+
* Called by agent-manager when the user approves/denies in the UI.
|
|
2388
|
+
*/
|
|
2389
|
+
async respondToApproval(sessionId, approved, optionId) {
|
|
2390
|
+
const queue = this.pendingPermissions.get(sessionId);
|
|
2391
|
+
if (!queue || queue.length === 0) {
|
|
2392
|
+
// Fallback: try the V2 API for pending permissions.
|
|
2393
|
+
// This handles the case where pendingPermissions was lost (e.g., app restart
|
|
2394
|
+
// or watchdog abort) but the permission is still pending in OpenCode.
|
|
2395
|
+
console.warn(`[OpencodeAdapter] No pending permission in memory for session ${sessionId}, trying V2 API fallback`);
|
|
2396
|
+
return await this.respondToPermissionViaV2(sessionId, approved, optionId);
|
|
2397
|
+
}
|
|
2398
|
+
const pending = queue.shift();
|
|
2399
|
+
if (queue.length === 0) {
|
|
2400
|
+
this.pendingPermissions.delete(sessionId);
|
|
2401
|
+
}
|
|
2402
|
+
// OpenCode expects: "once" (allow this time), "always" (remember), or "reject" (deny)
|
|
2403
|
+
let response;
|
|
2404
|
+
if (!approved) {
|
|
2405
|
+
response = 'reject';
|
|
2406
|
+
}
|
|
2407
|
+
else if (optionId === 'allow-always' ||
|
|
2408
|
+
optionId === 'approved-for-session') {
|
|
2409
|
+
response = 'always';
|
|
2410
|
+
}
|
|
2411
|
+
else {
|
|
2412
|
+
response = 'once';
|
|
2413
|
+
}
|
|
2414
|
+
console.log(`[OpencodeAdapter] Responding to permission ${pending.permissionId}: ${response}`);
|
|
2415
|
+
try {
|
|
2416
|
+
const baseUrl = this.serverUrl || DEFAULT_SERVER_URL;
|
|
2417
|
+
const url = `${baseUrl}/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(pending.permissionId)}`;
|
|
2418
|
+
const res = await fetch(url, {
|
|
2419
|
+
method: 'POST',
|
|
2420
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2421
|
+
body: JSON.stringify({ response })
|
|
2422
|
+
});
|
|
2423
|
+
if (!res.ok) {
|
|
2424
|
+
/*
|
|
2425
|
+
* A NON-OK STATUS IS A FAILED DELIVERY, AND IT MUST BE REPORTED AS ONE.
|
|
2426
|
+
*
|
|
2427
|
+
* This used to warn and fall through to `return true`. The caller then
|
|
2428
|
+
* recorded the answer as delivered, closed the question card, and wrote
|
|
2429
|
+
* "Approved" into the transcript — while the backend had never heard
|
|
2430
|
+
* it and the turn went on waiting for ever.
|
|
2431
|
+
*
|
|
2432
|
+
* OpenCode is the only backend wired into the cloud runner, so that
|
|
2433
|
+
* made the "your answer could not be delivered, please answer again"
|
|
2434
|
+
* recovery path UNREACHABLE FOR ITS PRIMARY TARGET.
|
|
2435
|
+
*/
|
|
2436
|
+
console.error(`[OpencodeAdapter] Permission response HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
2437
|
+
return false;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
catch (err) {
|
|
2441
|
+
console.error(`[OpencodeAdapter] Failed to respond to permission ${pending.permissionId}:`, err);
|
|
2442
|
+
// Reported, not swallowed — see the comment above.
|
|
2443
|
+
return false;
|
|
2444
|
+
}
|
|
2445
|
+
// Trigger immediate poll so agent-manager sees next pending permission (if any)
|
|
2446
|
+
if (this.onDataAvailable) {
|
|
2447
|
+
this.onDataAvailable(sessionId);
|
|
2448
|
+
}
|
|
2449
|
+
return true;
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Fallback: respond to a pending permission via the OpenCode V2 API.
|
|
2453
|
+
* Used when the in-memory pendingPermissions map is empty (e.g., after
|
|
2454
|
+
* app restart or watchdog abort) but the permission is still pending
|
|
2455
|
+
* in the OpenCode backend.
|
|
2456
|
+
*
|
|
2457
|
+
* Uses v2Client.permission.list() (top-level, lists all sessions) and
|
|
2458
|
+
* v2Client.permission.reply() to find and respond to the permission.
|
|
2459
|
+
*/
|
|
2460
|
+
async respondToPermissionViaV2(sessionId, approved, optionId) {
|
|
2461
|
+
try {
|
|
2462
|
+
if (!OpenCodeV2Client) {
|
|
2463
|
+
console.warn(`[OpencodeAdapter] Cannot fetch permissions — V2 SDK not loaded`);
|
|
2464
|
+
return false;
|
|
2465
|
+
}
|
|
2466
|
+
const v2 = this.v2Client ||
|
|
2467
|
+
OpenCodeV2Client.createOpencodeClient({
|
|
2468
|
+
baseUrl: this.serverUrl || DEFAULT_SERVER_URL
|
|
2469
|
+
});
|
|
2470
|
+
if (!this.v2Client)
|
|
2471
|
+
this.v2Client = v2;
|
|
2472
|
+
// permission.list() returns ALL pending permissions across all sessions.
|
|
2473
|
+
// Filter to the target session.
|
|
2474
|
+
const listResult = await v2.permission.list({});
|
|
2475
|
+
if (listResult.error || !listResult.data) {
|
|
2476
|
+
console.warn(`[OpencodeAdapter] V2 permission.list() failed or returned no data for session ${sessionId}`);
|
|
2477
|
+
return false;
|
|
2478
|
+
}
|
|
2479
|
+
const allPending = listResult.data;
|
|
2480
|
+
const sessionPending = allPending.filter((p) => p.sessionID === sessionId);
|
|
2481
|
+
if (sessionPending.length === 0) {
|
|
2482
|
+
console.warn(`[OpencodeAdapter] No pending permissions found via V2 API for session ${sessionId}`);
|
|
2483
|
+
return false;
|
|
2484
|
+
}
|
|
2485
|
+
const first = sessionPending[0];
|
|
2486
|
+
let reply;
|
|
2487
|
+
if (!approved) {
|
|
2488
|
+
reply = 'reject';
|
|
2489
|
+
}
|
|
2490
|
+
else if (optionId === 'allow-always' ||
|
|
2491
|
+
optionId === 'approved-for-session') {
|
|
2492
|
+
reply = 'always';
|
|
2493
|
+
}
|
|
2494
|
+
else {
|
|
2495
|
+
reply = 'once';
|
|
2496
|
+
}
|
|
2497
|
+
const directory = this.sessionWorkspaceDirs.get(sessionId);
|
|
2498
|
+
console.log(`[OpencodeAdapter] Responding to permission ${first.id} via V2 API: ${reply} (permission=${first.permission}, patterns=${first.patterns.join(', ')})`);
|
|
2499
|
+
await v2.permission.reply({
|
|
2500
|
+
requestID: first.id,
|
|
2501
|
+
reply,
|
|
2502
|
+
...(directory && { directory })
|
|
2503
|
+
});
|
|
2504
|
+
// Trigger immediate poll so agent-manager sees the unblocked session
|
|
2505
|
+
if (this.onDataAvailable) {
|
|
2506
|
+
this.onDataAvailable(sessionId);
|
|
2507
|
+
}
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
catch (err) {
|
|
2511
|
+
console.error(`[OpencodeAdapter] V2 permission fallback failed for session ${sessionId}:`, err);
|
|
2512
|
+
return false;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Subscribes to the OpenCode server's SSE event stream to capture
|
|
2517
|
+
* `permission.asked` events. Must be called after the server is running.
|
|
2518
|
+
* Runs in the background; reconnects automatically on disconnect.
|
|
2519
|
+
*/
|
|
2520
|
+
startEventSubscription() {
|
|
2521
|
+
if (this.sseAbort || !this.serverUrl)
|
|
2522
|
+
return;
|
|
2523
|
+
this.sseAbort = new AbortController();
|
|
2524
|
+
// Fire-and-forget — reconnection loop runs in the background
|
|
2525
|
+
this.processEventStream(this.sseAbort.signal).catch(() => { });
|
|
2526
|
+
}
|
|
2527
|
+
async processEventStream(signal) {
|
|
2528
|
+
const baseUrl = this.serverUrl || DEFAULT_SERVER_URL;
|
|
2529
|
+
const url = `${baseUrl}/global/event`;
|
|
2530
|
+
while (!signal.aborted) {
|
|
2531
|
+
try {
|
|
2532
|
+
const response = await globalThis.fetch(url, {
|
|
2533
|
+
signal,
|
|
2534
|
+
headers: { Accept: 'text/event-stream' }
|
|
2535
|
+
});
|
|
2536
|
+
const reader = response.body?.getReader();
|
|
2537
|
+
if (!reader)
|
|
2538
|
+
return;
|
|
2539
|
+
const decoder = new TextDecoder();
|
|
2540
|
+
let buffer = '';
|
|
2541
|
+
while (!signal.aborted) {
|
|
2542
|
+
const { done, value } = await reader.read();
|
|
2543
|
+
if (done)
|
|
2544
|
+
break;
|
|
2545
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2546
|
+
// Parse newline-delimited SSE data lines
|
|
2547
|
+
let nlIdx;
|
|
2548
|
+
while ((nlIdx = buffer.indexOf('\n')) !== -1) {
|
|
2549
|
+
const line = buffer.slice(0, nlIdx).trim();
|
|
2550
|
+
buffer = buffer.slice(nlIdx + 1);
|
|
2551
|
+
if (line.startsWith('data: ') || line.startsWith('data:')) {
|
|
2552
|
+
const json = line.startsWith('data: ')
|
|
2553
|
+
? line.slice(6)
|
|
2554
|
+
: line.slice(5);
|
|
2555
|
+
if (!json)
|
|
2556
|
+
continue;
|
|
2557
|
+
try {
|
|
2558
|
+
const event = JSON.parse(json);
|
|
2559
|
+
this.handleServerEvent(event);
|
|
2560
|
+
}
|
|
2561
|
+
catch {
|
|
2562
|
+
// Not valid JSON — skip
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
catch (err) {
|
|
2569
|
+
if (signal.aborted)
|
|
2570
|
+
return;
|
|
2571
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
2572
|
+
return;
|
|
2573
|
+
console.warn('[OpencodeAdapter] SSE connection error, reconnecting in 3s:', err instanceof Error ? err.message : err);
|
|
2574
|
+
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* Handle a single SSE event from the OpenCode server.
|
|
2580
|
+
* We only care about `permission.asked` events.
|
|
2581
|
+
*
|
|
2582
|
+
* The /global/event endpoint wraps events in a `payload` envelope:
|
|
2583
|
+
* { payload: { id, type, properties } }
|
|
2584
|
+
* The /event endpoint returns events directly:
|
|
2585
|
+
* { id, type, properties }
|
|
2586
|
+
* We handle both formats.
|
|
2587
|
+
*/
|
|
2588
|
+
handleServerEvent(event) {
|
|
2589
|
+
// Unwrap /global/event payload envelope if present
|
|
2590
|
+
const inner = (event.payload || event);
|
|
2591
|
+
const type = inner.type;
|
|
2592
|
+
if (type !== 'permission.asked')
|
|
2593
|
+
return;
|
|
2594
|
+
const props = (inner.properties || inner);
|
|
2595
|
+
const permissionId = (props.id || props.permissionID);
|
|
2596
|
+
const sessionID = (props.sessionID || props.sessionId);
|
|
2597
|
+
const permission = (props.permission || props.name || 'unknown');
|
|
2598
|
+
const patterns = (props.patterns || []);
|
|
2599
|
+
if (!permissionId || !sessionID) {
|
|
2600
|
+
console.warn('[OpencodeAdapter] permission.asked event missing id or sessionID:', JSON.stringify(event).slice(0, 300));
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
console.log(`[OpencodeAdapter] Permission requested: ${permission} for session ${sessionID} (${permissionId}) patterns=${patterns.join(', ')}`);
|
|
2604
|
+
// Auto-approve if the agent's permission mode is 'allow'
|
|
2605
|
+
const mode = this.sessionPermissionModes.get(sessionID) || 'ask';
|
|
2606
|
+
if (mode === 'allow') {
|
|
2607
|
+
console.log(`[OpencodeAdapter] Auto-approving permission ${permissionId} (permissionMode=allow)`);
|
|
2608
|
+
this.autoApprovePermission(sessionID, permissionId).catch((err) => {
|
|
2609
|
+
console.error(`[OpencodeAdapter] Auto-approve failed for ${permissionId}:`, err);
|
|
2610
|
+
});
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
// Append to the session's permission queue for UI handling
|
|
2614
|
+
let queue = this.pendingPermissions.get(sessionID);
|
|
2615
|
+
if (!queue) {
|
|
2616
|
+
queue = [];
|
|
2617
|
+
this.pendingPermissions.set(sessionID, queue);
|
|
2618
|
+
}
|
|
2619
|
+
// Deduplicate by permissionId
|
|
2620
|
+
if (!queue.some((p) => p.permissionId === permissionId)) {
|
|
2621
|
+
queue.push({ permissionId, permission, patterns });
|
|
2622
|
+
}
|
|
2623
|
+
// Trigger immediate poll so agent-manager renders the approval prompt
|
|
2624
|
+
if (this.onDataAvailable) {
|
|
2625
|
+
this.onDataAvailable(sessionID);
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
/**
|
|
2629
|
+
* Silently approve a permission request (used when permissionMode is 'allow').
|
|
2630
|
+
* Uses the V2 SDK permission.reply() which hits the correct endpoint
|
|
2631
|
+
* (POST /permission/{requestID}/reply). The V1 endpoint
|
|
2632
|
+
* (POST /session/{id}/permissions/{permissionID}) returns 404 for
|
|
2633
|
+
* permissions created by the V2 system.
|
|
2634
|
+
*/
|
|
2635
|
+
async autoApprovePermission(sessionId, permissionId) {
|
|
2636
|
+
// Resolve the workspace directory for this session — the OpenCode server
|
|
2637
|
+
// may need it to properly scope the permission reply.
|
|
2638
|
+
const directory = this.sessionWorkspaceDirs.get(sessionId);
|
|
2639
|
+
try {
|
|
2640
|
+
// Try V2 SDK first — this is the correct endpoint for V2 permissions
|
|
2641
|
+
if (OpenCodeV2Client) {
|
|
2642
|
+
const v2 = this.v2Client ||
|
|
2643
|
+
OpenCodeV2Client.createOpencodeClient({
|
|
2644
|
+
baseUrl: this.serverUrl || DEFAULT_SERVER_URL
|
|
2645
|
+
});
|
|
2646
|
+
if (!this.v2Client)
|
|
2647
|
+
this.v2Client = v2;
|
|
2648
|
+
await v2.permission.reply({
|
|
2649
|
+
requestID: permissionId,
|
|
2650
|
+
reply: 'always',
|
|
2651
|
+
...(directory && { directory })
|
|
2652
|
+
});
|
|
2653
|
+
console.log(`[OpencodeAdapter] Auto-approved permission ${permissionId} via V2 API${directory ? ` (dir=${directory})` : ''}`);
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
// Fallback to raw fetch if V2 SDK is not available
|
|
2657
|
+
const baseUrl = this.serverUrl || DEFAULT_SERVER_URL;
|
|
2658
|
+
const dirQuery = directory
|
|
2659
|
+
? `?directory=${encodeURIComponent(directory)}`
|
|
2660
|
+
: '';
|
|
2661
|
+
const url = `${baseUrl}/permission/${encodeURIComponent(permissionId)}/reply${dirQuery}`;
|
|
2662
|
+
const res = await fetch(url, {
|
|
2663
|
+
method: 'POST',
|
|
2664
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2665
|
+
body: JSON.stringify({ reply: 'always' })
|
|
2666
|
+
});
|
|
2667
|
+
if (!res.ok) {
|
|
2668
|
+
console.warn(`[OpencodeAdapter] Auto-approve HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
catch (err) {
|
|
2672
|
+
console.error(`[OpencodeAdapter] Auto-approve failed for ${permissionId}:`, err);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
stopEventSubscription() {
|
|
2676
|
+
if (this.sseAbort) {
|
|
2677
|
+
this.sseAbort.abort();
|
|
2678
|
+
this.sseAbort = null;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
async stopServer() {
|
|
2682
|
+
this.stopEventSubscription();
|
|
2683
|
+
this.pendingPermissions.clear();
|
|
2684
|
+
if (this.serverInstance) {
|
|
2685
|
+
try {
|
|
2686
|
+
await this.serverInstance.close();
|
|
2687
|
+
}
|
|
2688
|
+
catch (error) {
|
|
2689
|
+
console.error('[OpencodeAdapter] Error stopping server:', error);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
else if (this.serverUrl) {
|
|
2693
|
+
// The server was adopted, not spawned by this app instance (see
|
|
2694
|
+
// findAccessibleServer), so it is not ours to close. Say so: an adopted
|
|
2695
|
+
// server outlives the app and keeps every MCP stdio child it ever
|
|
2696
|
+
// attached, which is how those children pile up over days.
|
|
2697
|
+
console.log(`[OpencodeAdapter] Not closing adopted opencode server at ${this.serverUrl} (not spawned by this instance)`);
|
|
2698
|
+
}
|
|
2699
|
+
// Reset client state in both cases — the adopted-server branch used to skip
|
|
2700
|
+
// this and leave stale clients pointing at a server the app no longer uses.
|
|
2701
|
+
this.serverInstance = null;
|
|
2702
|
+
this.serverUrl = null;
|
|
2703
|
+
this.sharedClient = null;
|
|
2704
|
+
this.quickClient = null;
|
|
2705
|
+
this.v2Client = null;
|
|
2706
|
+
this.serverStarting = null;
|
|
2707
|
+
this.configPushed = false;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
exports.OpencodeAdapter = OpencodeAdapter;
|