@nowcrew/daemon 0.5.45 → 0.5.46

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.
@@ -1,4 +1,4 @@
1
- export const LOCAL_EXECUTION_RUNTIMES = ["claude", "codex", "kimi"];
1
+ export const LOCAL_EXECUTION_RUNTIMES = ["claude", "codex", "kimi", "hermes", "opencode"];
2
2
  export const LOCAL_RUNTIME_CAPABILITIES = Object.freeze({
3
3
  claude: Object.freeze({
4
4
  transport: "claude-stream-json",
@@ -15,6 +15,16 @@ export const LOCAL_RUNTIME_CAPABILITIES = Object.freeze({
15
15
  nativeResume: true,
16
16
  systemPromptTransport: "protocol",
17
17
  }),
18
+ hermes: Object.freeze({
19
+ transport: "hermes-acp",
20
+ nativeResume: true,
21
+ systemPromptTransport: "file",
22
+ }),
23
+ opencode: Object.freeze({
24
+ transport: "opencode-json",
25
+ nativeResume: true,
26
+ systemPromptTransport: "file",
27
+ }),
18
28
  });
19
29
  export function runtimeCapability(runtime) {
20
30
  return LOCAL_RUNTIME_CAPABILITIES[runtime];
@@ -0,0 +1,26 @@
1
+ import { executableRuntimes } from "./runtime-capabilities.js";
2
+ export function conservativeExecutionRuntimes(installed) {
3
+ return executableRuntimes(installed)
4
+ .filter((runtime) => runtime === "claude" || runtime === "codex");
5
+ }
6
+ export function createRuntimeProbeCoordinator() {
7
+ const abort = new AbortController();
8
+ const flights = new Map();
9
+ return {
10
+ detect(installed, detector) {
11
+ const key = [...installed].sort().join("\0");
12
+ const existing = flights.get(key);
13
+ if (existing)
14
+ return existing;
15
+ const pending = detector(installed, abort.signal).catch((error) => {
16
+ flights.delete(key);
17
+ throw error;
18
+ });
19
+ flights.set(key, pending);
20
+ return pending;
21
+ },
22
+ stop() {
23
+ abort.abort();
24
+ },
25
+ };
26
+ }
@@ -77,6 +77,8 @@ export function createRuntimeStartupGate(limits, now = Date.now) {
77
77
  claude: activeByRuntime.get("claude") ?? 0,
78
78
  codex: activeByRuntime.get("codex") ?? 0,
79
79
  kimi: activeByRuntime.get("kimi") ?? 0,
80
+ hermes: activeByRuntime.get("hermes") ?? 0,
81
+ opencode: activeByRuntime.get("opencode") ?? 0,
80
82
  },
81
83
  }),
82
84
  };
@@ -193,10 +193,20 @@ export function mapCodexNotification(method, params) {
193
193
  type: "command_execution",
194
194
  command: value.item.command,
195
195
  ...(value.item.status === undefined ? {} : { status: value.item.status }),
196
+ ...(typeof value.item.exitCode === "number" ? { exit_code: value.item.exitCode } : {}),
196
197
  ...(value.item.aggregatedOutput == null ? {} : { aggregated_output: value.item.aggregatedOutput }),
197
198
  },
198
199
  }];
199
200
  }
201
+ if (value.item.type === "fileChange" && Array.isArray(value.item.changes)) {
202
+ return [{
203
+ type: "diagnostic.file_change",
204
+ ...(value.item.status === undefined ? {} : { status: value.item.status }),
205
+ changes: value.item.changes.flatMap((change) => typeof change.path === "string"
206
+ ? [{ path: change.path, ...(change.kind === undefined ? {} : { kind: change.kind }) }]
207
+ : []),
208
+ }];
209
+ }
200
210
  return [];
201
211
  }
202
212
  async function readRunnerInput() {
@@ -0,0 +1,117 @@
1
+ import { once } from "node:events";
2
+ import { Readable, Writable } from "node:stream";
3
+ import spawn from "cross-spawn";
4
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from "@agentclientprotocol/sdk";
5
+ import { augmentedPath } from "../runtime-path.js";
6
+ const HERMES_MODEL_PROBE_TIMEOUT_MS = 8_000;
7
+ function nonEmptyString(value) {
8
+ if (typeof value !== "string")
9
+ return null;
10
+ const trimmed = value.trim();
11
+ return trimmed.length > 0 ? trimmed : null;
12
+ }
13
+ function selectOptions(options, category) {
14
+ const aliases = category === "model"
15
+ ? new Set(["model"])
16
+ : new Set(["reasoning", "effort", "thought level", "thinking level"]);
17
+ const matches = (options ?? []).filter((option) => (option.type === "select" && (option.category === category
18
+ || aliases.has(option.id.toLowerCase())
19
+ || aliases.has(option.name.toLowerCase()))));
20
+ if (matches.length !== 1)
21
+ return null;
22
+ const match = matches[0];
23
+ const values = match.options.flatMap((option) => "options" in option ? option.options : [option]);
24
+ return {
25
+ current: nonEmptyString(match.currentValue),
26
+ values: values.map((option) => ({ modelId: option.value, name: option.name })),
27
+ };
28
+ }
29
+ export function normalizeHermesModels(session) {
30
+ const modelConfig = selectOptions(session.configOptions, "model");
31
+ const effortConfig = selectOptions(session.configOptions, "thought_level");
32
+ const extension = session.models;
33
+ const rows = extension?.availableModels ?? extension?.available_models ?? modelConfig?.values ?? [];
34
+ const current = nonEmptyString(extension?.currentModelId)
35
+ ?? nonEmptyString(extension?.current_model_id)
36
+ ?? modelConfig?.current
37
+ ?? null;
38
+ const reasoning = [...new Set((effortConfig?.values ?? [])
39
+ .map((option) => nonEmptyString(option.modelId ?? option.model_id ?? option.id))
40
+ .filter((value) => value !== null))];
41
+ const seen = new Set();
42
+ return rows.flatMap((row) => {
43
+ const id = nonEmptyString(row.modelId ?? row.model_id ?? row.id);
44
+ if (id === null || seen.has(id))
45
+ return [];
46
+ seen.add(id);
47
+ return [{
48
+ id,
49
+ label: nonEmptyString(row.name) ?? id,
50
+ ...(id === current ? { default: true } : {}),
51
+ ...(reasoning.length > 0 ? { reasoning } : {}),
52
+ }];
53
+ });
54
+ }
55
+ async function stopChild(child) {
56
+ if (child.exitCode !== null || child.signalCode !== null)
57
+ return;
58
+ const closed = once(child, "close").then(() => undefined);
59
+ child.kill("SIGTERM");
60
+ let timer;
61
+ const graceful = await Promise.race([
62
+ closed.then(() => true),
63
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), 1_000); }),
64
+ ]);
65
+ if (timer !== undefined)
66
+ clearTimeout(timer);
67
+ if (!graceful && child.exitCode === null && child.signalCode === null) {
68
+ child.kill("SIGKILL");
69
+ await closed;
70
+ }
71
+ }
72
+ export async function listHermesModels(options = {}) {
73
+ const spawnProcess = options.spawnProcess ?? spawn;
74
+ const child = spawnProcess("hermes", ["acp"], {
75
+ cwd: process.cwd(),
76
+ env: { ...process.env, PATH: augmentedPath() },
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ });
79
+ if (child.stdin === null || child.stdout === null || child.stderr === null)
80
+ return [];
81
+ child.stderr.resume();
82
+ const app = client({ name: "nowcrew-daemon-hermes-models" })
83
+ .onRequest(methods.client.session.requestPermission, () => ({
84
+ outcome: { outcome: "cancelled" },
85
+ }))
86
+ .onNotification(methods.client.session.update, () => undefined);
87
+ let timer;
88
+ try {
89
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
90
+ const discovery = app.connectWith(stream, async (context) => {
91
+ await context.request(methods.agent.initialize, {
92
+ protocolVersion: PROTOCOL_VERSION,
93
+ clientCapabilities: {},
94
+ clientInfo: { name: "nowcrew-daemon", version: "1" },
95
+ });
96
+ const session = await context.request(methods.agent.session.new, {
97
+ cwd: process.cwd(),
98
+ mcpServers: [],
99
+ });
100
+ return normalizeHermesModels(session);
101
+ });
102
+ return await Promise.race([
103
+ discovery,
104
+ new Promise((resolve) => {
105
+ timer = setTimeout(() => {
106
+ void stopChild(child);
107
+ resolve([]);
108
+ }, HERMES_MODEL_PROBE_TIMEOUT_MS);
109
+ }),
110
+ ]);
111
+ }
112
+ finally {
113
+ if (timer !== undefined)
114
+ clearTimeout(timer);
115
+ await stopChild(child);
116
+ }
117
+ }
@@ -0,0 +1,6 @@
1
+ import spawn from "cross-spawn";
2
+ import { probeKimiAcp } from "./kimi-acp-runner.js";
3
+ /** Hermes and Kimi expose the same ACP initialize contract; execution policy stays provider-specific. */
4
+ export function probeHermesAcp(options, spawnProcess = spawn) {
5
+ return probeKimiAcp({ ...options, provider: "hermes" }, spawnProcess);
6
+ }
@@ -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
+ }