@tagma/sdk 0.5.1 → 0.6.0
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 -21
- package/dist/drivers/opencode.d.ts.map +1 -1
- package/dist/drivers/opencode.js +141 -1
- package/dist/drivers/opencode.js.map +1 -1
- package/package.json +2 -2
- package/scripts/preinstall.js +31 -31
- package/src/adapters/stdin-approval.ts +106 -106
- package/src/adapters/websocket-approval.ts +224 -224
- package/src/approval.ts +131 -131
- package/src/completions/exit-code.ts +34 -34
- package/src/completions/file-exists.ts +66 -66
- package/src/config-ops.ts +307 -307
- package/src/dag.ts +245 -245
- package/src/drivers/opencode.ts +371 -204
- package/src/logger.ts +182 -182
- package/src/prompt-doc.ts +49 -49
- package/src/task-ref.test.ts +401 -401
- package/src/task-ref.ts +120 -120
- package/src/triggers/file.ts +164 -164
- package/src/triggers/manual.ts +86 -86
- package/src/types.ts +18 -18
- package/src/utils.ts +203 -203
- package/dist/templates.d.ts +0 -20
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js +0 -93
- package/dist/templates.js.map +0 -1
package/src/drivers/opencode.ts
CHANGED
|
@@ -1,204 +1,371 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
DriverPlugin,
|
|
3
|
-
DriverCapabilities,
|
|
4
|
-
DriverResultMeta,
|
|
5
|
-
TaskConfig,
|
|
6
|
-
TrackConfig,
|
|
7
|
-
DriverContext,
|
|
8
|
-
SpawnSpec,
|
|
9
|
-
} from '../types';
|
|
10
|
-
|
|
11
|
-
const DEFAULT_MODEL = 'opencode/big-pickle';
|
|
12
|
-
|
|
13
|
-
// NOTE on Windows multi-line prompts: `opencode` resolves to `opencode.cmd`,
|
|
14
|
-
// an npm-generated batch wrapper. cmd.exe silently truncates argv elements
|
|
15
|
-
// at the first newline, so a multi-line prompt reaches the model as only
|
|
16
|
-
// its first line. The SDK's runner auto-unwraps npm .cmd shims into direct
|
|
17
|
-
// `node <js-entry>` invocations so newlines survive, and this driver can
|
|
18
|
-
// keep using the bare `opencode` name on every platform.
|
|
19
|
-
|
|
20
|
-
// tagma uses a provider-neutral reasoning_effort vocabulary (low|medium|high)
|
|
21
|
-
// but opencode's `--variant` is provider-specific (e.g. high|max|minimal).
|
|
22
|
-
// Map the tagma values to the closest opencode variant:
|
|
23
|
-
// low → minimal (least thinking)
|
|
24
|
-
// medium → <no flag, provider default>
|
|
25
|
-
// high → high (most thinking)
|
|
26
|
-
// Unknown values pass through unchanged so users who target a specific
|
|
27
|
-
// opencode variant (e.g. "max") still work.
|
|
28
|
-
const EFFORT_TO_VARIANT: Record<string, string | null> = {
|
|
29
|
-
low: 'minimal',
|
|
30
|
-
medium: null,
|
|
31
|
-
high: 'high',
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1
|
+
import type {
|
|
2
|
+
DriverPlugin,
|
|
3
|
+
DriverCapabilities,
|
|
4
|
+
DriverResultMeta,
|
|
5
|
+
TaskConfig,
|
|
6
|
+
TrackConfig,
|
|
7
|
+
DriverContext,
|
|
8
|
+
SpawnSpec,
|
|
9
|
+
} from '../types';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MODEL = 'opencode/big-pickle';
|
|
12
|
+
|
|
13
|
+
// NOTE on Windows multi-line prompts: `opencode` resolves to `opencode.cmd`,
|
|
14
|
+
// an npm-generated batch wrapper. cmd.exe silently truncates argv elements
|
|
15
|
+
// at the first newline, so a multi-line prompt reaches the model as only
|
|
16
|
+
// its first line. The SDK's runner auto-unwraps npm .cmd shims into direct
|
|
17
|
+
// `node <js-entry>` invocations so newlines survive, and this driver can
|
|
18
|
+
// keep using the bare `opencode` name on every platform.
|
|
19
|
+
|
|
20
|
+
// tagma uses a provider-neutral reasoning_effort vocabulary (low|medium|high)
|
|
21
|
+
// but opencode's `--variant` is provider-specific (e.g. high|max|minimal).
|
|
22
|
+
// Map the tagma values to the closest opencode variant:
|
|
23
|
+
// low → minimal (least thinking)
|
|
24
|
+
// medium → <no flag, provider default>
|
|
25
|
+
// high → high (most thinking)
|
|
26
|
+
// Unknown values pass through unchanged so users who target a specific
|
|
27
|
+
// opencode variant (e.g. "max") still work.
|
|
28
|
+
const EFFORT_TO_VARIANT: Record<string, string | null> = {
|
|
29
|
+
low: 'minimal',
|
|
30
|
+
medium: null,
|
|
31
|
+
high: 'high',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Auto-install + free-model picker ───────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// The opencode driver is SDK-built-in, but the `opencode` CLI isn't; we
|
|
37
|
+
// auto-install it on demand (via `bun install -g opencode-ai`) and pick a
|
|
38
|
+
// sensible default model from whatever the CLI reports. Both checks are
|
|
39
|
+
// process-cached via module-level variables so each concern runs at most
|
|
40
|
+
// once per SDK process.
|
|
41
|
+
//
|
|
42
|
+
// Design:
|
|
43
|
+
// - User-provided `model:` wins; we only compute a default when it's empty.
|
|
44
|
+
// - Failure modes never throw — they fall back to `DEFAULT_MODEL` and let
|
|
45
|
+
// the subsequent `opencode run` spawn fail with its own error. Avoids
|
|
46
|
+
// two confusing errors for one missing dependency.
|
|
47
|
+
|
|
48
|
+
interface OpencodeModelInfo {
|
|
49
|
+
id?: string;
|
|
50
|
+
providerID?: string;
|
|
51
|
+
status?: string;
|
|
52
|
+
cost?: { input?: number; output?: number };
|
|
53
|
+
limit?: { context?: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let opencodeReady: boolean | undefined;
|
|
57
|
+
let cachedDefaultModel: string | undefined;
|
|
58
|
+
|
|
59
|
+
async function runCapture(
|
|
60
|
+
args: string[],
|
|
61
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
62
|
+
try {
|
|
63
|
+
const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' });
|
|
64
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
65
|
+
new Response(proc.stdout).text(),
|
|
66
|
+
new Response(proc.stderr).text(),
|
|
67
|
+
proc.exited,
|
|
68
|
+
]);
|
|
69
|
+
return { code, stdout, stderr };
|
|
70
|
+
} catch {
|
|
71
|
+
return { code: -1, stdout: '', stderr: '' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function ensureOpencodeInstalled(): Promise<boolean> {
|
|
76
|
+
if (opencodeReady !== undefined) return opencodeReady;
|
|
77
|
+
|
|
78
|
+
// Probe existing install first — users who already have it get no delay.
|
|
79
|
+
const probe = await runCapture(['opencode', '--version']);
|
|
80
|
+
if (probe.code === 0) {
|
|
81
|
+
opencodeReady = true;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.error(
|
|
86
|
+
'[driver:opencode] opencode CLI not found — installing via `bun install -g opencode-ai`... (this may take up to a minute)',
|
|
87
|
+
);
|
|
88
|
+
// Use inherit here so the user sees bun's own progress during the one-time
|
|
89
|
+
// install; runCapture would swallow it.
|
|
90
|
+
const install = Bun.spawn(['bun', 'install', '-g', 'opencode-ai'], {
|
|
91
|
+
stdout: 'inherit',
|
|
92
|
+
stderr: 'inherit',
|
|
93
|
+
});
|
|
94
|
+
const installCode = await install.exited;
|
|
95
|
+
if (installCode !== 0) {
|
|
96
|
+
console.error('[driver:opencode] install failed — opencode run will likely fail below.');
|
|
97
|
+
opencodeReady = false;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Bun installs globals under `~/.bun/bin` (or `%USERPROFILE%\.bun\bin`),
|
|
102
|
+
// which isn't on this process's cached PATH unless the user already has
|
|
103
|
+
// bun set up. Ask bun for the directory and prepend it so bare `opencode`
|
|
104
|
+
// resolves in this process without requiring a shell reload.
|
|
105
|
+
const bin = await runCapture(['bun', 'pm', 'bin', '-g']);
|
|
106
|
+
if (bin.code === 0) {
|
|
107
|
+
const dir = bin.stdout.trim();
|
|
108
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
109
|
+
const current = process.env.PATH ?? '';
|
|
110
|
+
if (dir && !current.split(sep).includes(dir)) {
|
|
111
|
+
process.env.PATH = `${dir}${sep}${current}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const verify = await runCapture(['opencode', '--version']);
|
|
116
|
+
opencodeReady = verify.code === 0;
|
|
117
|
+
if (!opencodeReady) {
|
|
118
|
+
console.error(
|
|
119
|
+
'[driver:opencode] `opencode` still not resolvable after install — check that bun global bin is on PATH.',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return opencodeReady;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// `opencode models --verbose` emits "<provider>/<id>\n{...json...}\n" pairs.
|
|
126
|
+
// Walk balanced braces rather than split on newlines so we survive any
|
|
127
|
+
// whitespace oddities in the JSON payload.
|
|
128
|
+
function parseVerboseModels(stdout: string): OpencodeModelInfo[] {
|
|
129
|
+
const out: OpencodeModelInfo[] = [];
|
|
130
|
+
let depth = 0;
|
|
131
|
+
let start = -1;
|
|
132
|
+
for (let i = 0; i < stdout.length; i++) {
|
|
133
|
+
const c = stdout[i];
|
|
134
|
+
if (c === '{') {
|
|
135
|
+
if (depth === 0) start = i;
|
|
136
|
+
depth++;
|
|
137
|
+
} else if (c === '}') {
|
|
138
|
+
depth--;
|
|
139
|
+
if (depth === 0 && start !== -1) {
|
|
140
|
+
try {
|
|
141
|
+
out.push(JSON.parse(stdout.slice(start, i + 1)) as OpencodeModelInfo);
|
|
142
|
+
} catch {
|
|
143
|
+
/* skip malformed block */
|
|
144
|
+
}
|
|
145
|
+
start = -1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pickFreeModel(models: OpencodeModelInfo[]): string | null {
|
|
153
|
+
const fullId = (m: OpencodeModelInfo): string =>
|
|
154
|
+
`${m.providerID ?? 'opencode'}/${m.id ?? ''}`;
|
|
155
|
+
const eligible = models.filter((m) => {
|
|
156
|
+
if (!m.id || m.id === 'big-pickle') return false;
|
|
157
|
+
if (m.status && m.status !== 'active') return false;
|
|
158
|
+
const cost = m.cost;
|
|
159
|
+
if (!cost || cost.input !== 0 || cost.output !== 0) return false;
|
|
160
|
+
const ctx = m.limit?.context;
|
|
161
|
+
if (typeof ctx !== 'number' || ctx <= 128000) return false;
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
// Prefer models explicitly labelled "-free" by the provider — those are
|
|
165
|
+
// a stronger stability signal than "cost happens to be 0 right now".
|
|
166
|
+
const preferred = eligible.filter((m) => m.id?.endsWith('-free'));
|
|
167
|
+
const pool = preferred.length > 0 ? preferred : eligible;
|
|
168
|
+
if (pool.length === 0) return null;
|
|
169
|
+
// Deterministic pick: sort by full id so upstream model-list reordering
|
|
170
|
+
// doesn't flip our choice between runs.
|
|
171
|
+
pool.sort((a, b) => fullId(a).localeCompare(fullId(b)));
|
|
172
|
+
return fullId(pool[0]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function resolveDefaultModel(): Promise<string> {
|
|
176
|
+
if (cachedDefaultModel !== undefined) return cachedDefaultModel;
|
|
177
|
+
const ready = await ensureOpencodeInstalled();
|
|
178
|
+
if (!ready) {
|
|
179
|
+
cachedDefaultModel = DEFAULT_MODEL;
|
|
180
|
+
return cachedDefaultModel;
|
|
181
|
+
}
|
|
182
|
+
console.error('[driver:opencode] resolving free opencode model...');
|
|
183
|
+
const { code, stdout } = await runCapture(['opencode', 'models', '--verbose']);
|
|
184
|
+
if (code !== 0) {
|
|
185
|
+
cachedDefaultModel = DEFAULT_MODEL;
|
|
186
|
+
return cachedDefaultModel;
|
|
187
|
+
}
|
|
188
|
+
const picked = pickFreeModel(parseVerboseModels(stdout));
|
|
189
|
+
cachedDefaultModel = picked ?? DEFAULT_MODEL;
|
|
190
|
+
console.error(`[driver:opencode] default model: ${cachedDefaultModel}`);
|
|
191
|
+
return cachedDefaultModel;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const OpenCodeDriver: DriverPlugin = {
|
|
195
|
+
name: 'opencode',
|
|
196
|
+
|
|
197
|
+
capabilities: {
|
|
198
|
+
sessionResume: true, // supports --session
|
|
199
|
+
systemPrompt: false, // no --system-prompt flag; prepend to prompt instead
|
|
200
|
+
outputFormat: true, // supports --format json
|
|
201
|
+
} satisfies DriverCapabilities,
|
|
202
|
+
|
|
203
|
+
resolveModel(): string {
|
|
204
|
+
return DEFAULT_MODEL;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async buildCommand(task: TaskConfig, track: TrackConfig, ctx: DriverContext): Promise<SpawnSpec> {
|
|
208
|
+
const explicitModel = task.model ?? track.model;
|
|
209
|
+
// Always make sure the opencode CLI is usable before we spawn it — even
|
|
210
|
+
// when the user pinned a model. If missing, ensureOpencodeInstalled
|
|
211
|
+
// auto-installs it via `bun install -g opencode-ai`.
|
|
212
|
+
if (explicitModel) await ensureOpencodeInstalled();
|
|
213
|
+
// Otherwise resolveDefaultModel both ensures the CLI and picks a free
|
|
214
|
+
// model from `opencode models --verbose` (cached per-process).
|
|
215
|
+
const model = explicitModel ?? (await resolveDefaultModel());
|
|
216
|
+
// Resolve reasoning_effort → opencode --variant. SDK schema layer already
|
|
217
|
+
// resolved task → track → pipeline inheritance, so we only need to read
|
|
218
|
+
// task.reasoning_effort here.
|
|
219
|
+
const rawEffort = task.reasoning_effort ?? track.reasoning_effort;
|
|
220
|
+
const variant = rawEffort
|
|
221
|
+
? rawEffort in EFFORT_TO_VARIANT
|
|
222
|
+
? EFFORT_TO_VARIANT[rawEffort]
|
|
223
|
+
: rawEffort
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
let prompt = task.prompt!;
|
|
227
|
+
|
|
228
|
+
// agent_profile has no dedicated flag; prepend to prompt
|
|
229
|
+
const profile = task.agent_profile ?? track.agent_profile;
|
|
230
|
+
if (profile) {
|
|
231
|
+
prompt = `[Role]\n${profile}\n\n[Task]\n${prompt}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// continue_from: prefer session resume, fall back to text injection
|
|
235
|
+
let sessionId: string | null = null;
|
|
236
|
+
if (task.continue_from) {
|
|
237
|
+
sessionId = ctx.sessionMap.get(task.continue_from) ?? null;
|
|
238
|
+
if (!sessionId) {
|
|
239
|
+
// no session — degrade to text context passthrough
|
|
240
|
+
let prev: string | null = null;
|
|
241
|
+
if (ctx.normalizedMap.has(task.continue_from)) {
|
|
242
|
+
prev = ctx.normalizedMap.get(task.continue_from)!;
|
|
243
|
+
}
|
|
244
|
+
if (prev !== null) {
|
|
245
|
+
prompt = `[Previous Output]\n${prev}\n\n[Current Task]\n${prompt}`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// opencode run does not support stdin (no `-` placeholder like codex exec).
|
|
251
|
+
// Prompt is always a positional argument. Flags must be declared before `--`;
|
|
252
|
+
// the prompt follows after so that leading `--flag` content cannot be
|
|
253
|
+
// misread by opencode's argument parser (flag-injection mitigation).
|
|
254
|
+
// Shell-level injection is already prevented by Bun.spawn's direct argv array.
|
|
255
|
+
// Windows cmd.exe argv truncation on the `.cmd` wrapper is handled by the
|
|
256
|
+
// SDK runner's shim unwrapping — see note at the top of this file.
|
|
257
|
+
const args: string[] = [
|
|
258
|
+
'opencode',
|
|
259
|
+
'run',
|
|
260
|
+
'--model',
|
|
261
|
+
model,
|
|
262
|
+
'--format',
|
|
263
|
+
'json', // JSON output for parseResult
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
// `--variant` must precede `--` like every other flag. opencode rejects
|
|
267
|
+
// unknown variant names with a clear error, so we don't pre-validate.
|
|
268
|
+
if (variant) {
|
|
269
|
+
args.push('--variant', variant);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// session resume (must appear before --)
|
|
273
|
+
if (sessionId) {
|
|
274
|
+
args.push('--session', sessionId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// `--` (POSIX end-of-options) isolates prompt from flag parsing
|
|
278
|
+
args.push('--', prompt);
|
|
279
|
+
|
|
280
|
+
return { args, cwd: task.cwd ?? ctx.workDir };
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
parseResult(stdout: string): DriverResultMeta {
|
|
284
|
+
// opencode --format json emits NDJSON — one JSON object per line
|
|
285
|
+
// (step_start / text / step_finish / …). The previous single
|
|
286
|
+
// `JSON.parse(stdout)` always threw on this shape and fell through to
|
|
287
|
+
// the catch, returning sessionId:null and losing session resume.
|
|
288
|
+
// Walk line-by-line, pick up the first sessionID we see, concatenate
|
|
289
|
+
// any text-type parts into normalizedOutput, and bail early on error
|
|
290
|
+
// payloads.
|
|
291
|
+
const lines = stdout.split(/\r?\n/);
|
|
292
|
+
let sessionId: string | undefined;
|
|
293
|
+
const textParts: string[] = [];
|
|
294
|
+
let sawAnyJson = false;
|
|
295
|
+
let errorReason: string | null = null;
|
|
296
|
+
|
|
297
|
+
for (const raw of lines) {
|
|
298
|
+
const line = raw.trim();
|
|
299
|
+
if (!line) continue;
|
|
300
|
+
let json: Record<string, unknown>;
|
|
301
|
+
try {
|
|
302
|
+
json = JSON.parse(line) as Record<string, unknown>;
|
|
303
|
+
} catch {
|
|
304
|
+
continue; // tolerate interleaved non-JSON noise
|
|
305
|
+
}
|
|
306
|
+
sawAnyJson = true;
|
|
307
|
+
|
|
308
|
+
// M12: opencode sometimes emits {type:"error", error:{...}} with
|
|
309
|
+
// exit 0 for transient API failures. Force-fail so downstream
|
|
310
|
+
// skip_downstream / stop_all kicks in.
|
|
311
|
+
if (json.type === 'error') {
|
|
312
|
+
const err = json.error as { message?: unknown } | string | undefined;
|
|
313
|
+
const msg =
|
|
314
|
+
typeof err === 'object' && err !== null && typeof err.message === 'string'
|
|
315
|
+
? err.message
|
|
316
|
+
: typeof err === 'string'
|
|
317
|
+
? err
|
|
318
|
+
: null;
|
|
319
|
+
errorReason = msg
|
|
320
|
+
? `opencode reported error: ${msg}`
|
|
321
|
+
: 'opencode emitted an error JSON payload';
|
|
322
|
+
// D21: stop at the first error. Continuing meant subsequent text
|
|
323
|
+
// lines got accumulated into `textParts` only to be discarded by
|
|
324
|
+
// the error-return below, and a later `{type:"error"}` would
|
|
325
|
+
// silently overwrite the original cause — operators then debugged
|
|
326
|
+
// a downstream symptom while the root-cause line scrolled past.
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Session id — opencode uses `sessionID` (camelCase with capital D).
|
|
331
|
+
// Keep `session_id` / `sessionId` as fallbacks for forward/backward
|
|
332
|
+
// compatibility with other shapes.
|
|
333
|
+
if (!sessionId) {
|
|
334
|
+
const sid =
|
|
335
|
+
(json.sessionID as string | undefined) ??
|
|
336
|
+
(json.session_id as string | undefined) ??
|
|
337
|
+
(json.sessionId as string | undefined) ??
|
|
338
|
+
null;
|
|
339
|
+
if (typeof sid === 'string' && sid.length > 0) sessionId = sid;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Extract human-readable text from text-type parts.
|
|
343
|
+
if (json.type === 'text') {
|
|
344
|
+
const part = json.part as { text?: unknown } | undefined;
|
|
345
|
+
if (part && typeof part.text === 'string') {
|
|
346
|
+
textParts.push(part.text);
|
|
347
|
+
}
|
|
348
|
+
} else if (typeof json.result === 'string') {
|
|
349
|
+
textParts.push(json.result);
|
|
350
|
+
} else if (typeof json.content === 'string') {
|
|
351
|
+
textParts.push(json.content);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (errorReason) {
|
|
356
|
+
return { forceFailure: true, forceFailureReason: errorReason };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// If nothing parsed as JSON, treat stdout as plain text.
|
|
360
|
+
const normalizedOutput = !sawAnyJson
|
|
361
|
+
? stdout
|
|
362
|
+
: textParts.length > 0
|
|
363
|
+
? textParts.join('\n')
|
|
364
|
+
: stdout;
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
sessionId,
|
|
368
|
+
normalizedOutput,
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
};
|