@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,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 {};
|