-
Notifications
You must be signed in to change notification settings - Fork 10.7k
Expand file tree
/
Copy pathopencode.ts
More file actions
184 lines (177 loc) · 7.23 KB
/
Copy pathopencode.ts
File metadata and controls
184 lines (177 loc) · 7.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import { DEFAULT_MODEL_OPTION } from './shared.js';
import {
OPENCODE_PERMISSION_CAPABILITY,
appendOpenCodePermissionBypass,
appendOpenCodeWorkspaceDir,
} from '../opencode-permissions.js';
import { getRememberedLiveModels } from '../models.js';
import type { RuntimeAgentDef, RuntimeModelOption } from '../types.js';
const OPENCODE_VARIANT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
const OPENCODE_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;
function reasoningOptions(ids: readonly string[]): RuntimeModelOption[] {
return [
{ id: 'default', label: 'Default' },
...ids.map((id) => ({ id, label: id })),
];
}
const OPENCODE_FALLBACK_MODELS: RuntimeModelOption[] = [
DEFAULT_MODEL_OPTION,
{
id: 'anthropic/claude-sonnet-4-5',
label: 'anthropic/claude-sonnet-4-5',
},
{
id: 'openai/gpt-5.6-sol',
label: 'openai/gpt-5.6-sol',
},
{
id: 'openai/gpt-5.6-terra',
label: 'openai/gpt-5.6-terra',
},
{
id: 'openai/gpt-5.6-luna',
label: 'openai/gpt-5.6-luna',
},
{ id: 'openai/gpt-5', label: 'openai/gpt-5' },
{ id: 'google/gemini-2.5-pro', label: 'google/gemini-2.5-pro' },
];
function parseVerboseModelMetadata(
lines: string[],
start: number,
): { value: Record<string, unknown> | null; end: number } {
let buffer = '';
for (let index = start; index < lines.length; index += 1) {
buffer += `${lines[index]}\n`;
if (!lines[index]!.trimEnd().endsWith('}')) continue;
try {
const parsed = JSON.parse(buffer) as unknown;
return {
value: parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null,
end: index,
};
} catch {
// Nested objects close before the outer metadata object. Keep reading
// until the complete JSON value parses.
}
}
return { value: null, end: start - 1 };
}
/**
* Parse `opencode models --verbose`, retaining each model's exact variant
* names. Plain one-id-per-line output remains accepted as a compatibility
* fallback, but only verbose metadata can advertise reasoning choices.
*/
export function parseOpenCodeModels(stdout: string): RuntimeModelOption[] | null {
const lines = String(stdout || '').split('\n');
const models: RuntimeModelOption[] = [DEFAULT_MODEL_OPTION];
const seen = new Set<string>();
for (let index = 0; index < lines.length; index += 1) {
const id = lines[index]!.trim();
if (!OPENCODE_MODEL_ID.test(id) || seen.has(id)) continue;
seen.add(id);
const next = lines[index + 1]?.trimStart();
const metadata = next?.startsWith('{')
? parseVerboseModelMetadata(lines, index + 1)
: { value: null, end: index };
const variants = metadata.value?.['variants'];
const variantIds = variants && typeof variants === 'object' && !Array.isArray(variants)
? Object.keys(variants).filter((variant) => OPENCODE_VARIANT_ID.test(variant))
: [];
models.push({
id,
label: id,
...(variantIds.length > 0
? { reasoningOptions: reasoningOptions(variantIds) }
: {}),
});
index = Math.max(index, metadata.end);
}
return models.length > 1 ? models : null;
}
function supportsOpenCodeVariant(
modelId: string | null | undefined,
variant: string | null | undefined,
): variant is string {
if (!modelId || modelId === 'default' || !variant || variant === 'default') return false;
const live = getRememberedLiveModels('opencode').find((model) => model.id === modelId);
return Boolean(live?.reasoningOptions?.some((option) => option.id === variant));
}
export const opencodeAgentDef = {
id: 'opencode',
name: 'OpenCode',
bin: 'opencode-cli',
fallbackBins: ['opencode'],
versionArgs: ['--version'],
...OPENCODE_PERMISSION_CAPABILITY,
// `opencode models` prints `provider/model` per line. Real-world
// `opencode models` calls can take >8s (network round-trip to the
// provider registry), so the previous 8s budget timed out and fell back
// to the hardcoded `fallbackModels`, hiding the user's actual catalog.
// 15s matches the listModels budget the rest of the agent defs use
// (devin, hermes, kiro, kilo, kimi, trae-cli, vibe, reasonix).
listModels: {
args: ['models', '--verbose'],
parse: parseOpenCodeModels,
timeoutMs: 15_000,
},
fallbackModels: OPENCODE_FALLBACK_MODELS,
// OpenCode 1.18.x exposes provider/model-specific variants. Detection
// reads the exact live variant keys from `models --verbose`. The fallback
// keeps Sol/Terra/Luna model ids usable during a catalog outage but does
// not guess their variants. Unknown model/variant pairs omit `--variant`
// rather than inventing a provider capability or preventing the base
// model from running.
//
// Prompt delivered via stdin (`opencode run` with no message argv) to
// avoid Windows `spawn ENAMETOOLONG` while preserving OpenCode's
// structured stream. A literal `-` is parsed as a positional message by
// OpenCode 1.14.x and can surface as "Session not found".
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
const args = [
'run',
'--format',
'json',
];
appendOpenCodePermissionBypass(args, 'opencode');
appendOpenCodeWorkspaceDir(args, runtimeContext.cwd);
// Capture-style resume: OpenCode mints its own session id (reported on
// the stream as `sessionID`, e.g. `ses_...`). On a follow-up turn the
// daemon continues that session with `-s <id>` instead of re-sending the
// flattened transcript, so the first upstream call reuses the warm prefix
// cache. `-s` continues an EXISTING session (the create turn passes no id
// and we capture the one OpenCode generated), mirroring codex.
const resumeSessionId =
typeof runtimeContext.resumeSessionId === 'string' &&
runtimeContext.resumeSessionId.length > 0
? runtimeContext.resumeSessionId
: null;
if (resumeSessionId) {
args.push('-s', resumeSessionId);
}
if (options.model && options.model !== 'default') {
args.push('-m', options.model);
}
if (supportsOpenCodeVariant(options.model, options.reasoning)) {
args.push('--variant', options.reasoning);
}
return args;
},
promptViaStdin: true,
// OpenCode's CLI carries its own session across spawns: on a follow-up turn
// the daemon resumes the captured session id (`-s <id>`) instead of
// re-flattening the transcript. Capture-style — the resume handle is the
// `sessionID` captured from the stream, not a daemon-minted id.
resumesSessionViaCli: true,
capturesSessionIdFromStream: true,
streamFormat: 'json-event-stream',
eventParser: 'opencode',
// OpenCode reads MCP servers from its layered config (global ~/.config
// /opencode/opencode.json + project opencode.json + OPENCODE_CONFIG
// + OPENCODE_CONFIG_CONTENT). The env-var form lets the daemon hand
// user-configured external MCP servers to a single `opencode run`
// invocation without polluting the user's saved config files. See
// <https://opencode.ai/docs/config> and issue #2142.
externalMcpInjection: 'opencode-env-content',
} satisfies RuntimeAgentDef;