@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,270 @@
1
+ "use strict";
2
+ /**
3
+ * Model-gateway seam for the extracted coding-agent adapters.
4
+ *
5
+ * In the 20x Electron app the adapters read the enterprise AI gateway
6
+ * credentials directly from SQLite via `src/main/enterprise-ai-gateway.ts`.
7
+ * That module cannot be extracted because it depends on Electron's
8
+ * `safeStorage` for encrypt/decrypt. The harness therefore takes the already
9
+ * decrypted config through an injected `ModelGatewayConfigSource` instead.
10
+ *
11
+ * `ENTERPRISE_AI_GATEWAY_PROVIDER_ID`, `ENTERPRISE_AI_GATEWAY_PROVIDER_NAME`
12
+ * and `buildEnterpriseAiGatewayProviderConfig` are copied verbatim (behaviour
13
+ * identical) from 20x `src/main/enterprise-ai-gateway.ts`; only the formatting
14
+ * follows this package's Prettier config.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.SessionModelGateway = exports.ENTERPRISE_AI_GATEWAY_PROVIDER_NAME = exports.ENTERPRISE_AI_GATEWAY_PROVIDER_ID = void 0;
18
+ exports.buildEnterpriseAiGatewayProviderConfig = buildEnterpriseAiGatewayProviderConfig;
19
+ exports.buildPiAiGatewayProviderConfig = buildPiAiGatewayProviderConfig;
20
+ exports.__clearGatewayModelCacheForTest = __clearGatewayModelCacheForTest;
21
+ exports.fetchGatewayModels = fetchGatewayModels;
22
+ exports.ENTERPRISE_AI_GATEWAY_PROVIDER_ID = 'peakflo';
23
+ exports.ENTERPRISE_AI_GATEWAY_PROVIDER_NAME = 'Peakflo';
24
+ function buildEnterpriseAiGatewayProviderConfig(stored) {
25
+ /*
26
+ * REAL LIMITS, OR NONE AT ALL.
27
+ *
28
+ * opencode has no capability data for this custom provider, so without a
29
+ * `limit` it applies its own default of 32000 output tokens. LiteLLM refuses
30
+ * rather than clamps:
31
+ *
32
+ * max_tokens is too large: 32000. This model supports at most 16384
33
+ * completion tokens, whereas you provided 32000.
34
+ *
35
+ * The turn failed before producing one token, and the message named the
36
+ * MODEL rather than the default nobody chose.
37
+ *
38
+ * The fix is NOT a conservative constant. The gateway reports the true
39
+ * numbers per model at `/model/info`, so `fetchGatewayModels` reads them and
40
+ * they are passed straight through here. A model the gateway says nothing
41
+ * about gets NO `limit` key, and opencode keeps its default — because a
42
+ * ceiling invented for an unknown model is how the 32000 failure happened in
43
+ * the first place.
44
+ *
45
+ * BOTH KEYS OR NEITHER: opencode rejects the entire config with
46
+ * ConfigInvalidError when `limit` carries only one of them, which makes every
47
+ * model uncallable. `fetchGatewayLimits` therefore emits a limit only when it
48
+ * has both.
49
+ */
50
+ const models = (stored.models ?? []).reduce((acc, model) => {
51
+ acc[model.id] = model.limit
52
+ ? { name: model.name, limit: model.limit }
53
+ : { name: model.name };
54
+ return acc;
55
+ }, {});
56
+ return {
57
+ [exports.ENTERPRISE_AI_GATEWAY_PROVIDER_ID]: {
58
+ npm: '@ai-sdk/openai-compatible',
59
+ name: exports.ENTERPRISE_AI_GATEWAY_PROVIDER_NAME,
60
+ options: {
61
+ baseURL: stored.baseUrl,
62
+ apiKey: stored.apiKey
63
+ },
64
+ models
65
+ }
66
+ };
67
+ }
68
+ /** Build the provider entry Pi stores under `providers.peakflo`. */
69
+ function buildPiAiGatewayProviderConfig(stored) {
70
+ return {
71
+ name: exports.ENTERPRISE_AI_GATEWAY_PROVIDER_NAME,
72
+ baseUrl: stored.baseUrl,
73
+ api: 'openai-completions',
74
+ // The per-turn virtual key is resolved from the child environment. It is
75
+ // never persisted in models.json.
76
+ apiKey: '$PEAKFLO_AI_GATEWAY_API_KEY',
77
+ models: (stored.models ?? []).map((model) => ({
78
+ id: model.id,
79
+ name: model.name,
80
+ reasoning: false,
81
+ input: ['text', 'image'],
82
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
83
+ ...(model.limit?.context == null
84
+ ? {}
85
+ : { contextWindow: model.limit.context }),
86
+ ...(model.limit?.output == null ? {} : { maxTokens: model.limit.output })
87
+ }))
88
+ };
89
+ }
90
+ /* -------------------------------------------------------------------------- */
91
+ /* The per-session holder */
92
+ /* -------------------------------------------------------------------------- */
93
+ /**
94
+ * The gateway config for the turn that is running right now.
95
+ *
96
+ * Created ONCE and re-pointed per turn, exactly like `SessionTokenInjector`
97
+ * and for the same reason: the adapter is constructed once and holds this
98
+ * reference for the whole process life, so swapping the object would leave it
99
+ * reading a credential from a session that has already ended.
100
+ *
101
+ * `readModelGatewayConfig()` returns `null` when no turn is live, which is the
102
+ * same answer `readEnterpriseAiGatewayConfig(db)` gives in 20x when no gateway
103
+ * is configured. Null means "no gateway provider" — it does NOT mean "fall
104
+ * back to whatever credentials happen to be on this machine".
105
+ */
106
+ class SessionModelGateway {
107
+ config = null;
108
+ readModelGatewayConfig() {
109
+ return this.config;
110
+ }
111
+ /** Install the gateway for the turn about to run. */
112
+ set(config) {
113
+ this.config = config;
114
+ }
115
+ /**
116
+ * Swap in a re-minted credential without disturbing `baseUrl`/`models` — the
117
+ * LONG TURNS fix's answer to a fixed per-turn LiteLLM key TTL. A no-op when
118
+ * no turn is live (the config was already revoked or never set), which is
119
+ * the correct outcome for a refresh that raced the turn's own end.
120
+ */
121
+ updateApiKey(apiKey) {
122
+ if (!this.config)
123
+ return;
124
+ this.config = { ...this.config, apiKey };
125
+ }
126
+ /** Drop the credential the moment the turn ends. */
127
+ revoke() {
128
+ this.config = null;
129
+ }
130
+ }
131
+ exports.SessionModelGateway = SessionModelGateway;
132
+ /**
133
+ * Fetch the gateway's model list the way 20x does.
134
+ *
135
+ * 20x (`src/main/enterprise-auth.ts:905`) calls `${baseUrl}/models` — NOT
136
+ * `/v1/models`, and the two do not return the same set on the Peakflo
137
+ * deployment — with the tenant's virtual key, and accepts `id` OR `model_name`,
138
+ * because some LiteLLM deployments return only the latter. Reading one field
139
+ * silently drops half the catalogue.
140
+ *
141
+ * WHY THE HARNESS NEEDS THIS AT ALL. It used to register a provider containing
142
+ * exactly ONE model: the one the lease named. That is enough for the turn to
143
+ * run, and it makes the backend's own provider list a lie — `config.providers()`
144
+ * is what 20x's agent form reads to offer a choice, so a one-entry provider
145
+ * means the backend can never report what is actually reachable.
146
+ *
147
+ * FAILURE IS NOT FATAL. A gateway that will not answer must not stop a turn
148
+ * whose model is already known, so the caller falls back to the lease's single
149
+ * model. Returning an empty list here means "ask again later", never "there are
150
+ * no models".
151
+ */
152
+ /**
153
+ * The catalogue changes far more slowly than turns arrive.
154
+ *
155
+ * WITHOUT A CACHE THIS RAN ON EVERY TURN, adding a network round trip — up to
156
+ * the 10s timeout when the gateway is slow — to the front of every single one,
157
+ * and pointing every runner in the fleet at `/models` at the rate their users
158
+ * type. The list it fetches is a PROVIDER CATALOGUE: it changes when somebody
159
+ * edits a LiteLLM config, not between one message and the next.
160
+ *
161
+ * Keyed on the base url, NOT on the api key. The key is minted per turn, so
162
+ * keying on it would miss every time and the cache would be decorative. Two
163
+ * turns of the same tenant hitting the same gateway see the same catalogue,
164
+ * which is the point.
165
+ *
166
+ * A FAILURE IS NOT CACHED. An empty list is what this returns when the gateway
167
+ * cannot be reached, and remembering that for five minutes would extend one
168
+ * outage into a longer one for no reason.
169
+ */
170
+ const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
171
+ const modelCache = new Map();
172
+ /** Exported for tests, which must not inherit another test's catalogue. */
173
+ function __clearGatewayModelCacheForTest() {
174
+ modelCache.clear();
175
+ }
176
+ /**
177
+ * Per-model limits, keyed by the id `/models` uses.
178
+ *
179
+ * SEPARATE AND FAILURE-TOLERANT. A gateway that serves `/models` but not
180
+ * `/model/info` must still yield a usable model list, so this answers with an
181
+ * empty map rather than throwing. The caller then reports models with no
182
+ * limits, which is honest.
183
+ */
184
+ async function fetchGatewayLimits(params, base) {
185
+ const out = new Map();
186
+ try {
187
+ const response = await fetch(`${base}/model/info`, {
188
+ headers: { Authorization: `Bearer ${params.apiKey}` },
189
+ signal: AbortSignal.timeout(params.timeoutMs ?? 10_000)
190
+ });
191
+ if (!response.ok)
192
+ return out;
193
+ const body = (await response.json());
194
+ for (const entry of body.data ?? []) {
195
+ const id = typeof entry?.model_name === 'string' ? entry.model_name : '';
196
+ const info = (entry?.model_info ?? {});
197
+ const context = info.max_input_tokens;
198
+ const output = info.max_output_tokens;
199
+ // BOTH OR NEITHER: opencode rejects the whole config when `limit` is
200
+ // present with only one key, so a half-known model gets no limit at all.
201
+ if (id &&
202
+ typeof context === 'number' &&
203
+ typeof output === 'number' &&
204
+ context > 0 &&
205
+ output > 0) {
206
+ out.set(id, { context, output });
207
+ }
208
+ }
209
+ }
210
+ catch {
211
+ // Same reasoning as the caller: an enrichment must never fail a turn.
212
+ }
213
+ return out;
214
+ }
215
+ async function fetchGatewayModels(params) {
216
+ const cacheKey = params.baseUrl.replace(/\/+$/, '');
217
+ const hit = modelCache.get(cacheKey);
218
+ if (!params.force && hit && Date.now() - hit.at < MODEL_CACHE_TTL_MS) {
219
+ return hit.models;
220
+ }
221
+ const base = params.baseUrl.replace(/\/+$/, '');
222
+ try {
223
+ const response = await fetch(`${base}/models`, {
224
+ headers: { Authorization: `Bearer ${params.apiKey}` },
225
+ signal: AbortSignal.timeout(params.timeoutMs ?? 10_000)
226
+ });
227
+ if (!response.ok)
228
+ return [];
229
+ const body = (await response.json());
230
+ const models = (body.data ?? [])
231
+ .map((entry) => {
232
+ const id = typeof entry?.id === 'string' && entry.id
233
+ ? entry.id
234
+ : typeof entry?.model_name === 'string'
235
+ ? entry.model_name
236
+ : null;
237
+ return id ? { id, name: id } : null;
238
+ })
239
+ .filter((m) => m !== null);
240
+ /*
241
+ * ASK THE GATEWAY FOR THE LIMITS. DO NOT GUESS THEM.
242
+ *
243
+ * `/models` returns ids only, so an earlier version declared a single
244
+ * conservative ceiling for every model. That was a guess, and a guess is
245
+ * what broke the turn it was meant to fix: opencode's own default of 32000
246
+ * output tokens is above gpt-4o-mini's real ceiling of 16384, and LiteLLM
247
+ * refuses rather than clamps.
248
+ *
249
+ * `/model/info` reports the true numbers per model — gpt-4o-mini
250
+ * 128000/16384, gpt-4.1 1047576/32768 — so use them. A model the gateway
251
+ * says nothing about keeps NO limit, which leaves opencode on its own
252
+ * default: worse than a real number, better than a number we invented for
253
+ * a model we know nothing about.
254
+ */
255
+ const limits = await fetchGatewayLimits(params, base);
256
+ for (const model of models) {
257
+ const limit = limits.get(model.id);
258
+ if (limit)
259
+ model.limit = limit;
260
+ }
261
+ // Only a SUCCESSFUL answer is remembered — see the note on the cache.
262
+ modelCache.set(cacheKey, { at: Date.now(), models });
263
+ return models;
264
+ }
265
+ catch {
266
+ // Deliberately silent: this is an enrichment, and the turn proceeds on the
267
+ // model it was already given.
268
+ return [];
269
+ }
270
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Extracted verbatim from 20x `src/main/adapters/opencode-adapter.ts`.
3
+ * CHANGED (nothing else):
4
+ * - imports: `../utils/opencode-config` -> `./opencode-config`
5
+ * - `import type { DatabaseManager } from '../database'` -> the narrow
6
+ * structural `SettingsReader` type below (`getSetting` is the only member
7
+ * this file ever used; its real signature in 20x `src/main/database.ts` is
8
+ * `getSetting(key: string): string | undefined`).
9
+ * - the enterprise AI gateway is injected: `readEnterpriseAiGatewayConfig(this.db)`
10
+ * is now `this.gateway?.readModelGatewayConfig() ?? null`, because 20x's
11
+ * `enterprise-ai-gateway.ts` decrypts via Electron `safeStorage` and cannot
12
+ * be extracted. The constructor gained an optional second `gateway` param.
13
+ * The `@opencode-ai/sdk` type queries and dynamic imports are UNCHANGED; the
14
+ * SDK is an optional dependency and is declared ambiently in
15
+ * `./optional-agent-sdks.d.ts` so `tsc` succeeds when it is not installed.
16
+ * Source formatting (no semicolons) preserved.
17
+ */
18
+ import { OpencodeServerPolicy } from './opencode-server-policy';
19
+ import type { ModelGatewayConfigSource } from './model-gateway';
20
+ import type { CodingAgentAdapter, SessionConfig, SessionStatus, SessionMessage, MessagePart } from './coding-agent-adapter';
21
+ /**
22
+ * Narrow structural stand-in for 20x's `Pick<DatabaseManager, 'getSetting'>`.
23
+ * `getSetting` is the only member this file ever touched; the signature is
24
+ * copied from 20x `src/main/database.ts`.
25
+ */
26
+ type SettingsReader = {
27
+ getSetting(key: string): string | undefined;
28
+ };
29
+ export declare class OpencodeAdapter implements CodingAgentAdapter {
30
+ private db?;
31
+ private gateway?;
32
+ /**
33
+ * WHOSE OPENCODE SERVER THIS IS. Defaults to `shared`, which is 20x's
34
+ * behaviour and the reason the desktop is not regressed by this change:
35
+ * a host that passes nothing keeps exactly what it had. The HARNESS
36
+ * passes an owned policy (see `cli.ts`), and an owned policy never
37
+ * adopts, never binds a well-known port and never kills anything.
38
+ */
39
+ private serverPolicy;
40
+ /** Callback set by agent-manager to trigger an immediate poll cycle */
41
+ onDataAvailable?: (sessionId: string) => void;
42
+ private sdkLoading;
43
+ private serverInstance;
44
+ private serverUrl;
45
+ private serverStarting;
46
+ /** The shared V2 SDK client created alongside the server via createOpencode */
47
+ private sharedClient;
48
+ /** A separate V2 client with a reasonable timeout for quick operations (config, providers, health) */
49
+ private quickClient;
50
+ private clients;
51
+ /** Separate clients with no timeout, used ONLY for session.prompt() which runs indefinitely */
52
+ private promptClients;
53
+ private v2Client;
54
+ private promptAborts;
55
+ /** Provider errors captured from prompt results (surfaced via getStatus) */
56
+ private promptErrors;
57
+ /** Absolute paths to generated OpenCode plugin files registered for this session/workspace */
58
+ private pluginFilePaths;
59
+ /** Absolute paths to generated support files used by runtime plugins */
60
+ private runtimeSupportFilePaths;
61
+ /** Absolute path to the generated tillDone config support file */
62
+ private tillDoneConfigPath;
63
+ /** Whether the merged config has been pushed at least once to the running server.
64
+ * Config is pushed once on first server connection; subsequent pushes happen only
65
+ * via explicit `notifyConfigChanged()` calls (e.g. after settings edit or key rotation).
66
+ * This avoids PATCH /global/config storms that abort all running sessions. */
67
+ private configPushed;
68
+ /** Maximum number of automatic retries for transient prompt errors (e.g. "Aborted") */
69
+ private static readonly PROMPT_MAX_RETRIES;
70
+ /** Base delay (ms) for exponential backoff between prompt retries */
71
+ private static readonly PROMPT_RETRY_BASE_DELAY_MS;
72
+ /** Pending permission requests per session (captured from SSE events).
73
+ * Each session may have multiple pending permissions (parallel tool calls). */
74
+ private pendingPermissions;
75
+ /** Abort controller for the SSE event subscription */
76
+ private sseAbort;
77
+ /** Per-session permission mode ('ask' = surface in UI, 'allow' = auto-approve) */
78
+ private sessionPermissionModes;
79
+ /** Per-session workspace directory — needed for permission replies and other
80
+ * session-scoped V2 API calls initiated from global SSE events. */
81
+ private sessionWorkspaceDirs;
82
+ /** Per-session MCP server configs — retained for re-registration on session resume
83
+ * (e.g. after 20x restart when stdio MCP server processes are dead). */
84
+ private sessionMcpConfigs;
85
+ /** Per-session names of MCP servers that could not be attached. Read by the
86
+ * agent-manager so the session documentation does not advertise tools that
87
+ * are not there. */
88
+ private sessionMcpAttachFailures;
89
+ constructor(db?: SettingsReader | undefined, gateway?: ModelGatewayConfigSource | undefined,
90
+ /**
91
+ * WHOSE OPENCODE SERVER THIS IS. Defaults to `shared`, which is 20x's
92
+ * behaviour and the reason the desktop is not regressed by this change:
93
+ * a host that passes nothing keeps exactly what it had. The HARNESS
94
+ * passes an owned policy (see `cli.ts`), and an owned policy never
95
+ * adopts, never binds a well-known port and never kills anything.
96
+ */
97
+ serverPolicy?: OpencodeServerPolicy);
98
+ /** The state directory an OWNED server keeps its sessions in, else null. */
99
+ ownedStateDir(): string | null;
100
+ /**
101
+ * WHICH opencode can honour a `ses_...` handle this adapter minted.
102
+ *
103
+ * Not the process and not the port: two opencode servers sharing one state
104
+ * directory each read the other's sessions immediately, and a session
105
+ * outlives the server that created it. Measured against opencode 1.18.23.
106
+ * So the answer is an identity of the STATE DIRECTORY, and it is stable
107
+ * across both a server restart and a harness restart.
108
+ */
109
+ backendInstanceId(): string;
110
+ private loadSDK;
111
+ private ensureSDKLoaded;
112
+ initialize(): Promise<void>;
113
+ /**
114
+ * Wait for a group of MCP servers to reach a terminal state.
115
+ * Uses one status query per retry cycle (batched) instead of polling per server.
116
+ */
117
+ private waitForMcpServersReady;
118
+ /**
119
+ * Query MCP status via SDK.
120
+ * Prefer mcp.list() (same view as `opencode mcp list`), fall back to mcp.status().
121
+ */
122
+ private getMcpStatusMap;
123
+ private parseMcpListData;
124
+ /**
125
+ * Register MCP servers with the OpenCode backend (mcp.add + mcp.connect).
126
+ * Called from both createSession (initial setup) and resumeSession (after 20x
127
+ * restart when stdio MCP server processes are dead and need re-registration).
128
+ *
129
+ * Returns which servers are attached and which are not. The caller MUST act on
130
+ * the failures: OpenCode receives its MCP servers only through these runtime
131
+ * calls, so a swallowed failure produces a session that silently has no tools
132
+ * while AGENTS.md still advertises them.
133
+ */
134
+ private registerMcpServers;
135
+ /**
136
+ * Attach the MCP servers of a session and verify the result.
137
+ *
138
+ * OpenCode keeps MCP servers only in the memory of the `opencode serve`
139
+ * process, so an attach failure is invisible in the session itself: the agent
140
+ * simply has no task-management tools while AGENTS.md advertises them. This
141
+ * helper therefore retries the failures once, and reports what is still
142
+ * missing so the caller can escalate instead of continuing silently.
143
+ */
144
+ private attachAndVerifyMcpServers;
145
+ private setMcpAttachFailures;
146
+ /**
147
+ * Names of MCP servers that are configured for the session but are NOT
148
+ * attached to the OpenCode backend, so none of their tools can be called.
149
+ */
150
+ getMcpAttachFailures(sessionId: string): string[];
151
+ /**
152
+ * Disconnect the MCP servers that were attached for a session.
153
+ *
154
+ * Each attached stdio server is a child process of the shared `opencode serve`
155
+ * process, and it lives as long as that server does — which is days, because
156
+ * the server is shared and often adopted rather than spawned. Without this the
157
+ * stdio children accumulate one per session (the task-management-mcp leak).
158
+ * Servers still needed by another live session are kept.
159
+ */
160
+ private disconnectSessionMcpServers;
161
+ private getScopedPartId;
162
+ /**
163
+ * Returns the shared SDK client, ensuring the server is running first.
164
+ * Pass `quick: true` to get a client with a bounded timeout (for config queries, health checks).
165
+ */
166
+ private getClient;
167
+ /**
168
+ * Push the merged provider/auth config to the running OpenCode server.
169
+ *
170
+ * ⚠️ PATCH /global/config causes the server to call disposeAllInstancesAndEmitGlobalDisposed(),
171
+ * which aborts every running session processor AND disconnects all MCP servers.
172
+ *
173
+ * Strategy:
174
+ * - When sessions are active: push via directory-scoped PATCH /config for each
175
+ * active session directory. This updates the config without killing MCP connections.
176
+ * - When no sessions are active: safe to use global endpoint.
177
+ */
178
+ private pushMergedConfigToClient;
179
+ /**
180
+ * Notify the adapter that provider config has changed (e.g. user edited agent settings,
181
+ * AI gateway key was rotated). Pushes the updated config to the running server.
182
+ *
183
+ * Call this from the agent-manager when settings change — do NOT call it on every
184
+ * createSession or getClient, since PATCH /global/config aborts all running sessions.
185
+ */
186
+ notifyConfigChanged(): Promise<void>;
187
+ getProviders(serverUrl?: string, directory?: string): Promise<{
188
+ providers: {
189
+ id: string;
190
+ name: string;
191
+ models: unknown;
192
+ [key: string]: unknown;
193
+ }[];
194
+ default: Record<string, string>;
195
+ } | null>;
196
+ private getProvidersInner;
197
+ /**
198
+ * Remove models from the enterprise AI gateway provider that are no longer
199
+ * present in the fresh config stored in SQLite. Mutates the providers array
200
+ * in place.
201
+ */
202
+ private filterStaleProviderModels;
203
+ /**
204
+ * Recover from a broken opencode server by killing it, clearing its
205
+ * corrupted database, and resetting adapter state so the next call
206
+ * spawns a fresh server.
207
+ */
208
+ private recoverFromBrokenServer;
209
+ checkHealth(): Promise<{
210
+ available: boolean;
211
+ reason?: string;
212
+ }>;
213
+ private findAccessibleServer;
214
+ /**
215
+ * Ensures common binary install paths (e.g. ~/.opencode/bin) are in PATH
216
+ * so the SDK's createOpencode can find the `opencode` binary.
217
+ */
218
+ private ensureBinaryPaths;
219
+ private setBashTimeoutDefault;
220
+ /**
221
+ * Spawn THIS runner's own opencode server.
222
+ *
223
+ * Three properties, each deliberate:
224
+ * - a port the kernel chose, bound to LOOPBACK ONLY, so it collides with
225
+ * nothing and is reachable from nowhere else;
226
+ * - its own state directory, so its sessions are its own and are not
227
+ * pruned, listed or shared by anybody else's use of opencode;
228
+ * - spawned, so `stopServer` has a `serverInstance` to close and the
229
+ * "adopted, not mine to close" branch is unreachable.
230
+ *
231
+ * The state directory is passed through `XDG_DATA_HOME`, which is how
232
+ * opencode locates `opencode.db` — verified against opencode 1.18.23 by
233
+ * pointing a server at a private directory and finding its database there.
234
+ */
235
+ private startOwnedServer;
236
+ private ensureServerRunning;
237
+ createSession(config: SessionConfig): Promise<string>;
238
+ resumeSession(sessionId: string, config: SessionConfig): Promise<SessionMessage[]>;
239
+ sendPrompt(sessionId: string, parts: MessagePart[], config: SessionConfig): Promise<void>;
240
+ /**
241
+ * Returns true if the error message indicates a transient condition that
242
+ * may succeed on retry (e.g. OpenCode server aborted the session processor
243
+ * due to a config push, or the provider returned a temporary overload).
244
+ */
245
+ private isRetryablePromptError;
246
+ /**
247
+ * Executes a prompt with automatic retry for transient errors.
248
+ * Keeps the AbortController in promptAborts alive during retries so
249
+ * getStatus() continues to report BUSY.
250
+ */
251
+ private executePromptWithRetry;
252
+ getStatus(sessionId: string, config: SessionConfig): Promise<SessionStatus>;
253
+ /**
254
+ * Returns ERROR with captured prompt error if one exists, otherwise IDLE.
255
+ * Called from getStatus when the backend reports no active work.
256
+ */
257
+ private resolveIdleOrPromptError;
258
+ /**
259
+ * Transforms a raw OpenCode tool part into the structured format the renderer expects.
260
+ * OpenCode returns tool name as `part.tool` (string) and details in `part.state`.
261
+ */
262
+ private transformToolPart;
263
+ pollMessages(sessionId: string, seenMessageIds: Set<string>, seenPartIds: Set<string>, partContentLengths: Map<string, string>, config: SessionConfig): Promise<MessagePart[]>;
264
+ getRunningTools(sessionId: string, config: SessionConfig): Promise<Array<{
265
+ partId: string;
266
+ toolName: string;
267
+ startTime?: number;
268
+ input?: Record<string, unknown>;
269
+ }>>;
270
+ abortPrompt(sessionId: string, _config: SessionConfig): Promise<void>;
271
+ destroySession(sessionId: string, _config: SessionConfig): Promise<void>;
272
+ /**
273
+ * Writes all generated runtime plugin files needed for this session.
274
+ * MUST be called BEFORE ensureServerRunning() so plugins are discovered at startup.
275
+ */
276
+ private writeRuntimePluginFiles;
277
+ private writeSecretPlugin;
278
+ private writeTillDonePlugin;
279
+ private readTillDoneConfig;
280
+ private readTillDoneConfigFile;
281
+ private writeTillDoneConfig;
282
+ /**
283
+ * TELL OPENCODE TO ASK — defect G1's fifth wire, and the one that decides
284
+ * whether the other four are observable at all on this backend.
285
+ *
286
+ * THE PROBLEM. This adapter REACTS to `permission.asked`: it queues the
287
+ * permission, reports WAITING_APPROVAL, and offers getPendingApproval /
288
+ * respondToApproval. Nothing ever asked OpenCode to EMIT one. Only
289
+ * `external_directory` defaults to "ask" — `bash`, `edit` and `webfetch`
290
+ * default to allow — so the entire approval path was unreachable for every
291
+ * action a user would actually want to approve.
292
+ *
293
+ * MEASURED against a real `opencode serve` 1.18.25, real model, real `ls -a`:
294
+ * no `permission` in the effective config -> permission.asked x0
295
+ * `permission: {bash|edit|webfetch:'ask'}` -> permission.asked x1, shaped
296
+ * { id, sessionID, permission:'bash', patterns:['ls -a'], ... },
297
+ * exactly what `handleServerEvent` already parses.
298
+ *
299
+ * WHY A FILE IN THE SESSION'S WORKSPACE, AND NOT THE MERGED SERVER CONFIG.
300
+ *
301
+ * The obvious place is `buildMergedOpencodeConfig`, and it is WRONG in two
302
+ * ways that only showed up by running it:
303
+ *
304
+ * 1. IT IS SERVER-WIDE. One OpenCode server serves every session on the
305
+ * host — including, on a desktop, sessions this harness did not create.
306
+ * A verification run that set it there immediately began raising
307
+ * permission prompts for unrelated agent sessions belonging to the
308
+ * machine's owner.
309
+ * 2. IT PERSISTS. `client.global.config.update` WRITES THROUGH to
310
+ * `~/.config/opencode/opencode.json`. Setting a permission policy there
311
+ * permanently rewrites the user's own OpenCode configuration — a file
312
+ * this product does not own.
313
+ *
314
+ * A per-directory `opencode.json` has neither problem, and OpenCode really
315
+ * does honour it per session: with one shared server, `GET /config` returned
316
+ * `null` for the server's own cwd and `{bash:'ask',...}` for a session
317
+ * workspace containing this file. Verified before relying on it.
318
+ *
319
+ * WHY IT IS DERIVED FROM THE SESSION'S OWN MODE. Because it can be, now that
320
+ * it is per-session. A `bypassPermissions` agent writes `allow` and never
321
+ * pays a round trip; an asking agent writes `ask` and its permissions reach a
322
+ * human. One agent's choice cannot affect another's.
323
+ *
324
+ * THE MERGE IS DELIBERATELY ONE-WAY. Any `opencode.json` already in the
325
+ * workspace keeps its other keys, but NOT its permission block: a file in the
326
+ * workspace is attacker-influenceable — the agent can write there — so
327
+ * letting it weaken this would be defect G10's shape, an access control
328
+ * bypassable by a file on disk.
329
+ */
330
+ private writeSessionPermissionConfig;
331
+ private writeTillDoneSessionConfig;
332
+ private removeTillDoneSessionConfig;
333
+ private buildSecretInjectorPluginCode;
334
+ private buildTillDonePluginCode;
335
+ getAllMessages(sessionId: string, config: SessionConfig): Promise<SessionMessage[]>;
336
+ registerMcpServer(_serverName: string, _mcpConfig: {
337
+ type: 'local' | 'remote';
338
+ command?: string[];
339
+ url?: string;
340
+ headers?: Record<string, string>;
341
+ environment?: Record<string, string>;
342
+ }, _workspaceDir?: string): Promise<void>;
343
+ private getV2Client;
344
+ respondToQuestion(sessionId: string, answers: Record<string, string>, config: SessionConfig): Promise<void>;
345
+ /**
346
+ * Returns the first pending permission for a session, formatted as an
347
+ * approval request the agent-manager can render. The agent-manager
348
+ * duck-types for this method on any adapter during polling.
349
+ */
350
+ getPendingApproval(sessionId: string): {
351
+ toolCallId: string;
352
+ question: string;
353
+ options: Array<{
354
+ optionId: string;
355
+ name: string;
356
+ kind: string;
357
+ }>;
358
+ } | null;
359
+ /**
360
+ * Responds to a pending permission via the OpenCode HTTP API.
361
+ * Called by agent-manager when the user approves/denies in the UI.
362
+ */
363
+ respondToApproval(sessionId: string, approved: boolean, optionId?: string): Promise<boolean>;
364
+ /**
365
+ * Fallback: respond to a pending permission via the OpenCode V2 API.
366
+ * Used when the in-memory pendingPermissions map is empty (e.g., after
367
+ * app restart or watchdog abort) but the permission is still pending
368
+ * in the OpenCode backend.
369
+ *
370
+ * Uses v2Client.permission.list() (top-level, lists all sessions) and
371
+ * v2Client.permission.reply() to find and respond to the permission.
372
+ */
373
+ private respondToPermissionViaV2;
374
+ /**
375
+ * Subscribes to the OpenCode server's SSE event stream to capture
376
+ * `permission.asked` events. Must be called after the server is running.
377
+ * Runs in the background; reconnects automatically on disconnect.
378
+ */
379
+ private startEventSubscription;
380
+ private processEventStream;
381
+ /**
382
+ * Handle a single SSE event from the OpenCode server.
383
+ * We only care about `permission.asked` events.
384
+ *
385
+ * The /global/event endpoint wraps events in a `payload` envelope:
386
+ * { payload: { id, type, properties } }
387
+ * The /event endpoint returns events directly:
388
+ * { id, type, properties }
389
+ * We handle both formats.
390
+ */
391
+ private handleServerEvent;
392
+ /**
393
+ * Silently approve a permission request (used when permissionMode is 'allow').
394
+ * Uses the V2 SDK permission.reply() which hits the correct endpoint
395
+ * (POST /permission/{requestID}/reply). The V1 endpoint
396
+ * (POST /session/{id}/permissions/{permissionID}) returns 404 for
397
+ * permissions created by the V2 system.
398
+ */
399
+ private autoApprovePermission;
400
+ private stopEventSubscription;
401
+ stopServer(): Promise<void>;
402
+ }
403
+ export {};