@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. 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;