@nowcrew/daemon 0.5.45 → 0.5.47

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.
@@ -3,7 +3,7 @@ import { parseArgs } from "node:util";
3
3
  import { Readable, Writable } from "node:stream";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import spawn from "cross-spawn";
6
- import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from "@agentclientprotocol/sdk";
6
+ import { PROTOCOL_VERSION, RequestError, client, methods, ndJsonStream, } from "@agentclientprotocol/sdk";
7
7
  import { augmentedPath } from "../runtime-path.js";
8
8
  import { startFirstProgressWatchdog } from "./progress-watchdog.js";
9
9
  import { assertKimiLegacyPromptFits, buildKimiArgs } from "./kimi.js";
@@ -33,13 +33,13 @@ function textContent(content) {
33
33
  }).join("\n");
34
34
  }
35
35
  /** Translate stable ACP updates into daemon-owned NDJSON, without exposing thought chunks. */
36
- export function mapKimiAcpUpdate(update) {
36
+ export function mapAcpUpdate(provider, update) {
37
37
  if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
38
- return [{ type: "kimi.acp.text_delta", text: update.content.text }];
38
+ return [{ type: `${provider}.acp.text_delta`, text: update.content.text }];
39
39
  }
40
40
  if (update.sessionUpdate === "tool_call") {
41
41
  return [{
42
- type: "kimi.acp.tool_call",
42
+ type: `${provider}.acp.tool_call`,
43
43
  id: update.toolCallId,
44
44
  title: update.title,
45
45
  ...(update.kind === undefined ? {} : { kind: update.kind }),
@@ -50,7 +50,7 @@ export function mapKimiAcpUpdate(update) {
50
50
  if (update.sessionUpdate === "tool_call_update") {
51
51
  const output = textContent(update.content);
52
52
  return [{
53
- type: "kimi.acp.tool_result",
53
+ type: `${provider}.acp.tool_result`,
54
54
  id: update.toolCallId,
55
55
  ...(update.status === undefined ? {} : { status: update.status }),
56
56
  ...(output ? { content: output } : {}),
@@ -58,6 +58,9 @@ export function mapKimiAcpUpdate(update) {
58
58
  }
59
59
  return [];
60
60
  }
61
+ export function mapKimiAcpUpdate(update) {
62
+ return mapAcpUpdate("kimi", update);
63
+ }
61
64
  function safeErrorMessage(error, prompt) {
62
65
  const raw = error instanceof Error ? error.message : String(error);
63
66
  const redacted = prompt && raw.includes(prompt) ? raw.replaceAll(prompt, "[prompt redacted]") : raw;
@@ -67,6 +70,20 @@ export function isKimiAuthenticationRequired(error) {
67
70
  const message = error instanceof Error ? error.message : String(error);
68
71
  return /\bauthentication required\b/i.test(message);
69
72
  }
73
+ export function isAcpSessionNotFound(error) {
74
+ if (!(error instanceof RequestError) || (error.code !== -32602 && error.code !== -32603)) {
75
+ return false;
76
+ }
77
+ let data = "";
78
+ try {
79
+ data = JSON.stringify(error.data ?? "");
80
+ }
81
+ catch {
82
+ data = String(error.data ?? "");
83
+ }
84
+ return /session not found|no session found|unknown (?:durable )?session/i
85
+ .test(`${error.message} ${data}`);
86
+ }
70
87
  export function kimiResumeMethod(capabilities) {
71
88
  if (capabilities?.sessionCapabilities?.resume != null)
72
89
  return "resume";
@@ -88,6 +105,36 @@ export function selectKimiPermission(params) {
88
105
  ? { outcome: { outcome: "cancelled" } }
89
106
  : { outcome: { outcome: "selected", optionId: allowed.optionId } };
90
107
  }
108
+ function selectConfigId(options, category) {
109
+ const normalizedNames = category === "model"
110
+ ? new Set(["model"])
111
+ : new Set(["reasoning", "effort", "thought level", "thinking level"]);
112
+ const matches = (options ?? []).filter((option) => (option.type === "select"
113
+ && (option.category === category
114
+ || normalizedNames.has(option.id.toLowerCase())
115
+ || normalizedNames.has(option.name.toLowerCase()))));
116
+ return matches.length === 1 ? matches[0].id : null;
117
+ }
118
+ async function applySessionConfig(context, provider, sessionId, configOptions, options) {
119
+ const modelConfigId = provider === "kimi" && options.model
120
+ ? "model"
121
+ : selectConfigId(configOptions, "model");
122
+ if (options.model && modelConfigId !== null) {
123
+ await context.request(methods.agent.session.setConfigOption, {
124
+ sessionId,
125
+ configId: modelConfigId,
126
+ value: options.model,
127
+ });
128
+ }
129
+ const reasoningConfigId = selectConfigId(configOptions, "thought_level");
130
+ if (options.reasoning && reasoningConfigId !== null) {
131
+ await context.request(methods.agent.session.setConfigOption, {
132
+ sessionId,
133
+ configId: reasoningConfigId,
134
+ value: options.reasoning,
135
+ });
136
+ }
137
+ }
91
138
  async function readPrompt() {
92
139
  process.stdin.setEncoding("utf8");
93
140
  let prompt = "";
@@ -116,6 +163,7 @@ async function stopChild(child) {
116
163
  }
117
164
  /** Probe the ACP transport without starting a session or forcing an optional interactive login flow. */
118
165
  export async function probeKimiAcp(options, spawnProcess = spawn) {
166
+ const provider = options.provider ?? "kimi";
119
167
  const child = spawnProcess(options.bin, ["acp"], {
120
168
  cwd: process.cwd(),
121
169
  // probe 在 daemon 自身 PATH 下运行,补上用户级 CLI 目录,与 which 探测保持一致。
@@ -125,12 +173,13 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
125
173
  if (child.stdin === null || child.stdout === null || child.stderr === null)
126
174
  return false;
127
175
  child.stderr.resume();
128
- const app = client({ name: "nowcrew-daemon-kimi-probe" })
176
+ const app = client({ name: `nowcrew-daemon-${provider}-probe` })
129
177
  .onRequest(methods.client.session.requestPermission, () => ({
130
178
  outcome: { outcome: "cancelled" },
131
179
  }))
132
180
  .onNotification(methods.client.session.update, () => undefined);
133
181
  let timeout;
182
+ let onAbort;
134
183
  try {
135
184
  const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
136
185
  const connected = app.connectWith(stream, async (context) => {
@@ -149,6 +198,16 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
149
198
  resolve(false);
150
199
  }, PROBE_TIMEOUT_MS);
151
200
  }),
201
+ new Promise((resolve) => {
202
+ onAbort = () => {
203
+ void stopChild(child);
204
+ resolve(false);
205
+ };
206
+ if (options.signal?.aborted)
207
+ onAbort();
208
+ else
209
+ options.signal?.addEventListener("abort", onAbort, { once: true });
210
+ }),
152
211
  ]);
153
212
  return result;
154
213
  }
@@ -158,10 +217,14 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
158
217
  finally {
159
218
  if (timeout !== undefined)
160
219
  clearTimeout(timeout);
220
+ if (onAbort !== undefined)
221
+ options.signal?.removeEventListener("abort", onAbort);
161
222
  await stopChild(child);
162
223
  }
163
224
  }
164
225
  export async function runKimiAcp(options) {
226
+ const provider = options.provider ?? "kimi";
227
+ const displayName = provider === "kimi" ? "Kimi" : "Hermes";
165
228
  const prompt = await readPrompt();
166
229
  const child = spawn(options.bin, ["acp"], {
167
230
  cwd: process.cwd(),
@@ -170,7 +233,7 @@ export async function runKimiAcp(options) {
170
233
  });
171
234
  let runtimeChild = child;
172
235
  if (child.stdin === null || child.stdout === null || child.stderr === null) {
173
- throw new Error("Kimi ACP process did not expose stdio");
236
+ throw new Error(`${displayName} ACP process did not expose stdio`);
174
237
  }
175
238
  child.stderr.pipe(process.stderr, { end: false });
176
239
  let context = null;
@@ -192,12 +255,12 @@ export async function runKimiAcp(options) {
192
255
  };
193
256
  process.once("SIGTERM", onSignal);
194
257
  process.once("SIGINT", onSignal);
195
- const app = client({ name: "nowcrew-daemon-kimi" })
258
+ const app = client({ name: `nowcrew-daemon-${provider}` })
196
259
  .onRequest(methods.client.session.requestPermission, ({ params }) => selectKimiPermission(params))
197
260
  .onNotification(methods.client.session.update, async ({ params }) => {
198
261
  acpSemanticProgress = true;
199
262
  firstProgress.observe();
200
- for (const event of mapKimiAcpUpdate(params.update))
263
+ for (const event of mapAcpUpdate(provider, params.update))
201
264
  await jsonLine(event);
202
265
  });
203
266
  let firstProgress = startFirstProgressWatchdog(() => undefined);
@@ -211,29 +274,44 @@ export async function runKimiAcp(options) {
211
274
  clientCapabilities: {},
212
275
  clientInfo: { name: "nowcrew-daemon", version: "1" },
213
276
  });
214
- if (process.env.CREW_KIMI_ACP_DEBUG === "1") {
215
- process.stderr.write(`Kimi ACP auth methods: ${JSON.stringify(initialized.authMethods ?? [])}\n`);
277
+ if (provider === "kimi" && process.env.CREW_KIMI_ACP_DEBUG === "1") {
278
+ process.stderr.write(`${displayName} ACP auth methods: ${JSON.stringify(initialized.authMethods ?? [])}\n`);
216
279
  }
280
+ let configOptions;
217
281
  if (options.resume && options.sessionId) {
218
282
  const resumeMethod = kimiResumeMethod(initialized.agentCapabilities);
219
- if (resumeMethod === "resume") {
220
- await nextContext.request(methods.agent.session.resume, {
221
- sessionId: options.sessionId,
222
- cwd: process.cwd(),
223
- mcpServers: [],
224
- });
283
+ try {
284
+ if (resumeMethod === "resume") {
285
+ const resumed = await nextContext.request(methods.agent.session.resume, {
286
+ sessionId: options.sessionId,
287
+ cwd: process.cwd(),
288
+ mcpServers: [],
289
+ });
290
+ configOptions = resumed.configOptions;
291
+ }
292
+ else if (resumeMethod === "load") {
293
+ const loaded = await nextContext.request(methods.agent.session.load, {
294
+ sessionId: options.sessionId,
295
+ cwd: process.cwd(),
296
+ mcpServers: [],
297
+ });
298
+ configOptions = loaded.configOptions;
299
+ }
300
+ else {
301
+ throw new Error(`${displayName} ACP does not advertise session resume support`);
302
+ }
303
+ sessionId = options.sessionId;
225
304
  }
226
- else if (resumeMethod === "load") {
227
- await nextContext.request(methods.agent.session.load, {
228
- sessionId: options.sessionId,
305
+ catch (error) {
306
+ if (provider !== "hermes" || !isAcpSessionNotFound(error))
307
+ throw error;
308
+ const session = await nextContext.request(methods.agent.session.new, {
229
309
  cwd: process.cwd(),
230
310
  mcpServers: [],
231
311
  });
312
+ sessionId = session.sessionId;
313
+ configOptions = session.configOptions;
232
314
  }
233
- else {
234
- throw new Error("Kimi ACP does not advertise session resume support");
235
- }
236
- sessionId = options.sessionId;
237
315
  }
238
316
  else {
239
317
  const session = await nextContext.request(methods.agent.session.new, {
@@ -241,15 +319,10 @@ export async function runKimiAcp(options) {
241
319
  mcpServers: [],
242
320
  });
243
321
  sessionId = session.sessionId;
322
+ configOptions = session.configOptions;
244
323
  }
245
324
  await jsonLine({ type: "thread.started", thread_id: sessionId });
246
- if (options.model) {
247
- await nextContext.request(methods.agent.session.setConfigOption, {
248
- sessionId,
249
- configId: "model",
250
- value: options.model,
251
- });
252
- }
325
+ await applySessionConfig(nextContext, provider, sessionId, configOptions, options);
253
326
  firstProgress = startFirstProgressWatchdog(() => {
254
327
  progressTimedOut = true;
255
328
  void cancel();
@@ -261,7 +334,7 @@ export async function runKimiAcp(options) {
261
334
  });
262
335
  firstProgress.stop();
263
336
  if (progressTimedOut) {
264
- process.stderr.write("Kimi produced no semantic progress within the startup window\n");
337
+ process.stderr.write(`${displayName} produced no semantic progress within the startup window\n`);
265
338
  return 1;
266
339
  }
267
340
  const usage = result.usage;
@@ -281,10 +354,10 @@ export async function runKimiAcp(options) {
281
354
  }
282
355
  catch (error) {
283
356
  if (progressTimedOut) {
284
- process.stderr.write("Kimi produced no semantic progress within the startup window\n");
357
+ process.stderr.write(`${displayName} produced no semantic progress within the startup window\n`);
285
358
  return 1;
286
359
  }
287
- if (!acpSemanticProgress && isKimiAuthenticationRequired(error)) {
360
+ if (provider === "kimi" && !acpSemanticProgress && isKimiAuthenticationRequired(error)) {
288
361
  firstProgress.stop();
289
362
  await stopChild(child);
290
363
  process.stderr.write("Kimi ACP requires account login; falling back to configured CLI provider transport\n");
@@ -323,7 +396,7 @@ export async function runKimiAcp(options) {
323
396
  }
324
397
  return code;
325
398
  }
326
- process.stderr.write(`Kimi ACP execution failed: ${safeErrorMessage(error, prompt)}\n`);
399
+ process.stderr.write(`${displayName} ACP execution failed: ${safeErrorMessage(error, prompt)}\n`);
327
400
  return 1;
328
401
  }
329
402
  finally {
@@ -337,19 +410,26 @@ function optionsFromArgv(argv) {
337
410
  const { values } = parseArgs({
338
411
  args: [...argv],
339
412
  options: {
413
+ provider: { type: "string", default: "kimi" },
340
414
  bin: { type: "string" },
341
415
  model: { type: "string" },
416
+ reasoning: { type: "string" },
342
417
  session: { type: "string" },
343
418
  resume: { type: "boolean", default: false },
344
419
  },
345
420
  });
346
421
  if (!values.bin)
347
422
  throw new Error("--bin is required");
423
+ if (values.provider !== "kimi" && values.provider !== "hermes") {
424
+ throw new Error("--provider must be kimi or hermes");
425
+ }
348
426
  if (values.resume && !values.session)
349
427
  throw new Error("--resume requires --session");
350
428
  return {
429
+ provider: values.provider,
351
430
  bin: values.bin,
352
431
  ...(values.model ? { model: values.model } : {}),
432
+ ...(values.reasoning ? { reasoning: values.reasoning } : {}),
353
433
  ...(values.session ? { sessionId: values.session } : {}),
354
434
  ...(values.resume ? { resume: true } : {}),
355
435
  };
@@ -358,7 +438,7 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.a
358
438
  runKimiAcp(optionsFromArgv(process.argv.slice(2)))
359
439
  .then((code) => { process.exitCode = code; })
360
440
  .catch((error) => {
361
- process.stderr.write(`Kimi ACP runner failed: ${safeErrorMessage(error, "")}\n`);
441
+ process.stderr.write(`ACP runner failed: ${safeErrorMessage(error, "")}\n`);
362
442
  process.exitCode = 1;
363
443
  });
364
444
  }
@@ -0,0 +1,122 @@
1
+ import { once } from "node:events";
2
+ import { createInterface } from "node:readline";
3
+ import { parseArgs } from "node:util";
4
+ import { pathToFileURL } from "node:url";
5
+ import spawn from "cross-spawn";
6
+ import { augmentedPath } from "../runtime-path.js";
7
+ import { buildOpenCodeArgs, createOpenCodeEventDecoder } from "./opencode.js";
8
+ const ERROR_CAP = 2_000;
9
+ async function readPrompt() {
10
+ process.stdin.setEncoding("utf8");
11
+ let prompt = "";
12
+ for await (const chunk of process.stdin)
13
+ prompt += String(chunk);
14
+ if (!prompt)
15
+ throw new Error("OpenCode prompt is empty");
16
+ return prompt;
17
+ }
18
+ async function jsonLine(event) {
19
+ if (process.stdout.write(`${JSON.stringify(event)}\n`))
20
+ return;
21
+ await once(process.stdout, "drain");
22
+ }
23
+ export function isOpenCodeSessionNotFound(message) {
24
+ return /(?:session|conversation).*(?:not found|does not exist|unknown|invalid)|(?:not found|unknown|invalid).*(?:session|conversation)/iu
25
+ .test(message);
26
+ }
27
+ async function runAttempt(options, prompt) {
28
+ const cwd = process.cwd();
29
+ const child = spawn(options.bin, buildOpenCodeArgs({ cwd, ...options }), {
30
+ cwd,
31
+ env: { ...process.env, PATH: augmentedPath(), PWD: cwd },
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ });
34
+ if (!child.stdin || !child.stdout || !child.stderr) {
35
+ throw new Error("OpenCode process did not expose stdio");
36
+ }
37
+ child.stderr.pipe(process.stderr, { end: false });
38
+ const childExit = once(child, "close");
39
+ child.stdin.end(prompt);
40
+ const decoder = createOpenCodeEventDecoder();
41
+ const lines = createInterface({ input: child.stdout });
42
+ const pending = [];
43
+ let emittedSemanticOutput = false;
44
+ for await (const line of lines) {
45
+ let event;
46
+ try {
47
+ event = JSON.parse(line);
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ for (const normalized of decoder.push(event)) {
53
+ const type = normalized.type;
54
+ const semantic = type === "opencode.text_delta"
55
+ || type === "opencode.tool_call"
56
+ || type === "opencode.tool_result";
57
+ if (!emittedSemanticOutput && !semantic) {
58
+ pending.push(normalized);
59
+ continue;
60
+ }
61
+ if (!emittedSemanticOutput) {
62
+ emittedSemanticOutput = true;
63
+ for (const buffered of pending.splice(0))
64
+ await jsonLine(buffered);
65
+ }
66
+ await jsonLine(normalized);
67
+ }
68
+ }
69
+ const [exitCode, signal] = await childExit;
70
+ const result = decoder.finish();
71
+ const staleSession = options.sessionId !== undefined
72
+ && !emittedSemanticOutput
73
+ && result.error !== undefined
74
+ && isOpenCodeSessionNotFound(result.error);
75
+ if (staleSession)
76
+ return { code: 1, staleSession: true };
77
+ for (const buffered of pending)
78
+ await jsonLine(buffered);
79
+ if (!result.ok) {
80
+ process.stderr.write(`${result.error?.slice(0, ERROR_CAP)}\n`);
81
+ return { code: 1, staleSession: false };
82
+ }
83
+ if (exitCode !== 0)
84
+ return { code: exitCode ?? (signal ? 128 : 1), staleSession: false };
85
+ await jsonLine({ type: "turn.completed", ...(result.usage ? { usage: result.usage } : {}) });
86
+ return { code: 0, staleSession: false };
87
+ }
88
+ export async function runOpenCode(options) {
89
+ const prompt = await readPrompt();
90
+ const first = await runAttempt(options, prompt);
91
+ if (!first.staleSession)
92
+ return first.code;
93
+ const { sessionId: _staleSessionId, ...freshOptions } = options;
94
+ return (await runAttempt(freshOptions, prompt)).code;
95
+ }
96
+ function optionsFromArgv(argv) {
97
+ const { values } = parseArgs({
98
+ args: [...argv],
99
+ options: {
100
+ bin: { type: "string" },
101
+ model: { type: "string" },
102
+ reasoning: { type: "string" },
103
+ session: { type: "string" },
104
+ },
105
+ });
106
+ if (!values.bin)
107
+ throw new Error("--bin is required");
108
+ return {
109
+ bin: values.bin,
110
+ ...(values.model ? { model: values.model } : {}),
111
+ ...(values.reasoning ? { reasoning: values.reasoning } : {}),
112
+ ...(values.session ? { sessionId: values.session } : {}),
113
+ };
114
+ }
115
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
116
+ runOpenCode(optionsFromArgv(process.argv.slice(2)))
117
+ .then((code) => { process.exitCode = code; })
118
+ .catch((error) => {
119
+ process.stderr.write(`OpenCode runner failed: ${String(error).slice(0, ERROR_CAP)}\n`);
120
+ process.exitCode = 1;
121
+ });
122
+ }
@@ -0,0 +1,181 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { isWin } from "../platform.js";
4
+ import { augmentedPath } from "../runtime-path.js";
5
+ const execFileP = promisify(execFile);
6
+ const REQUIRED_RUN_FLAGS = [
7
+ "--format",
8
+ "--dangerously-skip-permissions",
9
+ "--dir",
10
+ "--model",
11
+ "--variant",
12
+ "--session",
13
+ ];
14
+ export async function probeOpenCodeRun(signal) {
15
+ try {
16
+ const { stdout, stderr } = await execFileP("opencode", ["run", "--help"], {
17
+ shell: isWin(),
18
+ timeout: 8_000,
19
+ killSignal: "SIGKILL",
20
+ maxBuffer: 1024 * 1024,
21
+ signal,
22
+ env: { ...process.env, PATH: augmentedPath() },
23
+ });
24
+ const help = `${stdout}\n${stderr}`;
25
+ return REQUIRED_RUN_FLAGS.every((flag) => help.includes(flag));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export function buildOpenCodeArgs(options) {
32
+ return [
33
+ "run", "--format", "json", "--dangerously-skip-permissions",
34
+ "--dir", options.cwd,
35
+ ...(options.model ? ["--model", options.model] : []),
36
+ ...(options.reasoning ? ["--variant", options.reasoning] : []),
37
+ ...(options.sessionId ? ["--session", options.sessionId] : []),
38
+ ];
39
+ }
40
+ function outputText(value) {
41
+ if (typeof value === "string")
42
+ return value;
43
+ if (value === undefined || value === null)
44
+ return "";
45
+ try {
46
+ return JSON.stringify(value);
47
+ }
48
+ catch {
49
+ return String(value);
50
+ }
51
+ }
52
+ function reportedUsage(part) {
53
+ if ((part.cost ?? 0) > 0)
54
+ return true;
55
+ const tokens = part.tokens;
56
+ if (!tokens)
57
+ return false;
58
+ return (tokens.input ?? 0) > 0 || (tokens.output ?? 0) > 0
59
+ || (tokens.reasoning ?? 0) > 0 || (tokens.total ?? 0) > 0
60
+ || (tokens.cache?.read ?? 0) > 0 || (tokens.cache?.write ?? 0) > 0;
61
+ }
62
+ export function createOpenCodeEventDecoder() {
63
+ let sessionId = null;
64
+ let announcedSession = null;
65
+ let finalText = "";
66
+ let error = null;
67
+ let openStep = false;
68
+ let stepHasContinuationTool = false;
69
+ let awaitingContinuation = false;
70
+ let stepProducedOutput = false;
71
+ let lastStepVoid = false;
72
+ let sawEvent = false;
73
+ let sawStepFinish = false;
74
+ const usage = {
75
+ input_tokens: 0,
76
+ output_tokens: 0,
77
+ cache_read_input_tokens: 0,
78
+ cache_creation_input_tokens: 0,
79
+ };
80
+ return {
81
+ push(raw) {
82
+ const event = (raw ?? {});
83
+ const out = [];
84
+ if (typeof event.type === "string" && event.type)
85
+ sawEvent = true;
86
+ if (typeof event.sessionID === "string" && event.sessionID) {
87
+ sessionId = event.sessionID;
88
+ if (announcedSession !== sessionId) {
89
+ announcedSession = sessionId;
90
+ out.push({ type: "thread.started", thread_id: sessionId });
91
+ }
92
+ }
93
+ const part = event.part ?? {};
94
+ if (event.type === "step_start") {
95
+ openStep = true;
96
+ awaitingContinuation = false;
97
+ stepHasContinuationTool = false;
98
+ stepProducedOutput = false;
99
+ out.push({ type: "opencode.step_start" });
100
+ }
101
+ else if (event.type === "text" && typeof part.text === "string" && part.text) {
102
+ finalText += part.text;
103
+ stepProducedOutput = true;
104
+ out.push({ type: "opencode.text_delta", text: part.text });
105
+ }
106
+ else if (event.type === "tool_use") {
107
+ stepProducedOutput = true;
108
+ if (part.metadata?.providerExecuted !== true)
109
+ stepHasContinuationTool = true;
110
+ out.push({
111
+ type: "opencode.tool_call",
112
+ ...(part.callID ? { id: part.callID } : {}),
113
+ ...(part.tool ? { title: part.tool } : {}),
114
+ ...(part.state?.input === undefined ? {} : { input: part.state.input }),
115
+ });
116
+ if (part.state?.status === "completed" || part.state?.status === "error") {
117
+ const content = part.state.status === "error" && part.state.error
118
+ ? part.state.error
119
+ : outputText(part.state.output);
120
+ out.push({
121
+ type: "opencode.tool_result",
122
+ ...(part.callID ? { id: part.callID } : {}),
123
+ status: part.state.status,
124
+ ...(content ? { content } : {}),
125
+ });
126
+ }
127
+ }
128
+ else if (event.type === "step_finish") {
129
+ openStep = false;
130
+ sawStepFinish = true;
131
+ awaitingContinuation = part.reason === "tool-calls"
132
+ || (Boolean(part.reason) && stepHasContinuationTool);
133
+ stepHasContinuationTool = false;
134
+ if (reportedUsage(part))
135
+ stepProducedOutput = true;
136
+ lastStepVoid = !stepProducedOutput;
137
+ const tokens = part.tokens;
138
+ if (tokens) {
139
+ usage.input_tokens += tokens.input ?? 0;
140
+ usage.output_tokens += tokens.output ?? 0;
141
+ usage.cache_read_input_tokens += tokens.cache?.read ?? 0;
142
+ usage.cache_creation_input_tokens += tokens.cache?.write ?? 0;
143
+ }
144
+ out.push({
145
+ type: "opencode.step_finish",
146
+ ...(part.reason ? { stop_reason: part.reason } : {}),
147
+ ...(tokens ? { usage: {
148
+ input_tokens: tokens.input ?? 0,
149
+ output_tokens: tokens.output ?? 0,
150
+ cache_read_input_tokens: tokens.cache?.read ?? 0,
151
+ cache_creation_input_tokens: tokens.cache?.write ?? 0,
152
+ } } : {}),
153
+ });
154
+ }
155
+ else if (event.type === "error") {
156
+ error = event.error?.data?.message || event.error?.name || "unknown OpenCode error";
157
+ out.push({ type: "opencode.error", message: error });
158
+ }
159
+ return out;
160
+ },
161
+ finish() {
162
+ const structuralError = openStep
163
+ ? "OpenCode stream ended while a step was still open"
164
+ : awaitingContinuation
165
+ ? "OpenCode stream ended before the required continuation"
166
+ : lastStepVoid
167
+ ? "OpenCode stream ended on an empty step"
168
+ : !sawEvent || !sawStepFinish
169
+ ? "OpenCode stream ended with no events proving completion"
170
+ : null;
171
+ const failure = error ?? structuralError;
172
+ return {
173
+ ok: failure === null,
174
+ ...(failure === null ? {} : { error: failure }),
175
+ finalText,
176
+ sessionId,
177
+ ...(sawStepFinish ? { usage } : {}),
178
+ };
179
+ },
180
+ };
181
+ }