@nowcrew/daemon 0.4.4 → 0.4.5

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,6 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- const execFileP = promisify(execFile);
3
+ import { isWin } from "./platform.js";
4
+ const execFileRaw = promisify(execFile);
5
+ // win32 上 npm CLI 是 .cmd shim,execFile 需 shell 才能执行;参数全是固定字面量,无注入面。
6
+ const execFileP = (bin, args) => execFileRaw(bin, args, { shell: isWin() });
4
7
  export async function listRuntimeModels(runtime) {
5
8
  switch (runtime) {
6
9
  case "codex":
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * 采集本机信息上报给控制面 (machine:hello):hostname / os / daemon 版本 / 已装 runtimes。
3
- * runtimes 探测靠 `which <bin>`,只报真实可执行的 CLI(用于展示 Detected Runtimes)。
3
+ * runtimes 探测靠 `which <bin>`(win32 用 `where`),只报真实可执行的 CLI(用于展示 Detected Runtimes)。
4
4
  */
5
5
  import { hostname, arch, platform } from "node:os";
6
+ import { lookupCmd } from "./platform.js";
6
7
  import { execFile } from "node:child_process";
7
8
  import { promisify } from "node:util";
8
9
  import { readFileSync } from "node:fs";
@@ -24,7 +25,7 @@ const RUNTIME_BINS = [
24
25
  ];
25
26
  async function isInstalled(bin) {
26
27
  try {
27
- await execFileP("which", [bin]);
28
+ await execFileP(lookupCmd(), [bin]);
28
29
  return true;
29
30
  }
30
31
  catch {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 平台差异集中点(win32 vs unix)。
3
+ * Windows 三个坑:没有 `which`(用 `where`);npm 全局 CLI 是 .cmd shim,
4
+ * spawn/execFile 不带 shell 无法执行(Node 18.20+ 直接拒绝);shebang 脚本不可执行。
5
+ */
6
+ export const isWin = (p = process.platform) => p === "win32";
7
+ /** 探测可执行文件用的命令:win32 = where,其余 = which。 */
8
+ export const lookupCmd = (p = process.platform) => (isWin(p) ? "where" : "which");
package/dist/runner.js CHANGED
@@ -9,7 +9,7 @@ import { prepareWorkspace, rotateAgentSession } from "./workspace.js";
9
9
  import { buildSystemPrompt, buildWakePrompt } from "./prompt.js";
10
10
  import { spawnClaude } from "./runtimes/claude.js";
11
11
  import { spawnCodex } from "./runtimes/codex.js";
12
- import { spawnKimi } from "./runtimes/kimi.js";
12
+ import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
13
13
  import { normalizeEvent, parseLine, extractRunMeta } from "./normalize.js";
14
14
  import { readSession, writeSession, pickResumeId } from "./session.js";
15
15
  import { toConsoleLines } from "./console.js";
@@ -77,8 +77,9 @@ onConsole = () => { }) {
77
77
  });
78
78
  await writeFile(ws.systemPromptPath, systemPrompt, "utf8");
79
79
  // 3) spawn runtime,注入 PATH(crew wrapper)、凭证 env、以及 agent 运行时配置
80
- // provider=custom → BYOC(ANTHROPIC_BASE_URL/API_KEY);reasoning → 思考预算;model → --model
81
- const REASONING_TOKENS = { low: "4000", medium: "10000", high: "31999" };
80
+ // provider=custom → BYOC(ANTHROPIC_BASE_URL/API_KEY);model → --model;
81
+ // reasoning 原生思考强度(claude --effort / codex -c model_reasoning_effort= /
82
+ // kimi KIMI_MODEL_THINKING_EFFORT env;档位白名单在各 runtime 适配器里,白名单外不传)。
82
83
  // resume 时提示 agent 上下文已在,无需从头重读频道(配合不注入 work-log,进一步省 token)。
83
84
  const baseWake = input.wake ?? buildWakePrompt(input.channelId);
84
85
  const wakePrompt = resuming
@@ -87,7 +88,7 @@ onConsole = () => { }) {
87
88
  const childEnv = {
88
89
  ...process.env,
89
90
  // 用户自定义 env(表单 ENVIRONMENT VARIABLES):先合入,可覆盖继承的 shell 环境;
90
- // 但 PATH/CREW_*/XDG_* 及下方由表单生成的值(ANTHROPIC_*/思考预算)在其后合入,始终以系统为准。
91
+ // 但 PATH/CREW_*/XDG_* 及下方由表单生成的值(ANTHROPIC_*/思考强度)在其后合入,始终以系统为准。
91
92
  ...sanitizeEnvVars(cfg.envVars),
92
93
  PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
93
94
  CREW_SERVER_URL: config.serverUrl,
@@ -109,10 +110,10 @@ onConsole = () => { }) {
109
110
  // provider custom = BYOC:为该 agent 单独设置 Anthropic 端点/密钥
110
111
  ...(cfg.provider === "custom" && cfg.providerBaseUrl ? { ANTHROPIC_BASE_URL: cfg.providerBaseUrl } : {}),
111
112
  ...(cfg.provider === "custom" && cfg.providerApiKey ? { ANTHROPIC_API_KEY: cfg.providerApiKey } : {}),
112
- // reasoning → 思考预算 (claude MAX_THINKING_TOKENS;kimi 读 KIMI_MODEL_THINKING_EFFORT,
113
- // 值域 low/medium/high/xhigh/max 与本配置兼容,对其它 runtime 无害)
114
- ...(cfg.reasoning && cfg.reasoning !== "default" && REASONING_TOKENS[cfg.reasoning]
115
- ? { MAX_THINKING_TOKENS: REASONING_TOKENS[cfg.reasoning], KIMI_MODEL_THINKING_EFFORT: cfg.reasoning }
113
+ // reasoning → kimi 无对应 CLI 参数,走 KIMI_MODEL_THINKING_EFFORT env(kimi-code 0.23.0,
114
+ // 值域 low/medium/high/xhigh/max);claude/codex 改走各自 spawn 原生参数(见下),不再注 env。
115
+ ...(runtime === "kimi" && cfg.reasoning && KIMI_EFFORT_LEVELS.includes(cfg.reasoning)
116
+ ? { KIMI_MODEL_THINKING_EFFORT: cfg.reasoning }
116
117
  : {}),
117
118
  // fast 模式 → 透传给 runtime(best-effort,供 wrapper/runtime 读取)
118
119
  ...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
@@ -125,6 +126,7 @@ onConsole = () => { }) {
125
126
  wakePrompt,
126
127
  dangerous: config.dangerous,
127
128
  ...(currentModel ? { model: currentModel } : {}),
129
+ ...(cfg.reasoning ? { reasoning: cfg.reasoning } : {}),
128
130
  // 一线程一会话(唯一会话机制):首轮/冷启动 --session-id 固定 uuid,warm 续轮 --resume 续上。
129
131
  ...(launchSessionId ? { sessionId: launchSessionId, resume: resuming } : {}),
130
132
  env: childEnv,
@@ -136,6 +138,7 @@ onConsole = () => { }) {
136
138
  wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
137
139
  dangerous: config.dangerous,
138
140
  ...(currentModel ? { model: currentModel } : {}),
141
+ ...(cfg.reasoning ? { reasoning: cfg.reasoning } : {}),
139
142
  env: childEnv,
140
143
  })
141
144
  : runtime === "kimi"
@@ -196,17 +199,22 @@ onConsole = () => { }) {
196
199
  for (const c of toConsoleLines(evt))
197
200
  onConsole(c);
198
201
  });
199
- child.stderr.on("data", (d) => process.stderr.write(d));
200
- const exitCode = await new Promise((resolve) => {
201
- child.on("close", (code) => resolve(code ?? 0));
202
+ // stderr 除透传本地终端外,再留一段尾部:runtime 启动即崩( codex 拒跑)时,
203
+ // 这是唯一的错误线索,要随 error 活动上送,否则失败对 server/web 完全不可见。
204
+ let stderrTail = "";
205
+ child.stderr.on("data", (d) => {
206
+ process.stderr.write(d);
207
+ stderrTail = (stderrTail + String(d)).slice(-STDERR_TAIL_CAP);
202
208
  });
203
- // kimi stream-json 没有轮次结束事件(进程退出即结束),补一个 done/error 活动对齐前端状态。
204
- if (runtime === "kimi") {
205
- const a = exitCode === 0
206
- ? { kind: "done", label: "本轮结束" }
207
- : { kind: "error", label: "运行出错", detail: `kimi exited with code ${exitCode}` };
208
- activities.push(a);
209
- onActivity(a);
209
+ const { exitCode, spawnError } = await awaitExit(child);
210
+ // spawn 本身失败(如 PATH 里没有 runtime 二进制)没有 stderr,把错误并入尾部供上报。
211
+ const errorTail = [stderrTail.trim(), spawnError].filter(Boolean).join(" ").trim();
212
+ const finish = exitActivity(runtime, exitCode, errorTail);
213
+ if (finish) {
214
+ activities.push(finish);
215
+ onActivity(finish);
216
+ if (finish.kind === "error")
217
+ onConsole({ stream: "error", text: `✖ ${finish.detail ?? finish.label}` });
210
218
  }
211
219
  if ((runtime === "codex" || runtime === "kimi") && exitCode === 0 && !sentViaCrew && finalText) {
212
220
  // force:兜底回帖锚定本轮触发消息的线程,语义上必须送达;不 force 时 agent(-p 单发不跑
@@ -244,6 +252,44 @@ onConsole = () => { }) {
244
252
  }
245
253
  return { exitCode, activities, model: observedModel, runtime, resumed: resuming, sessionId, ...(usage ? { usage } : {}) };
246
254
  }
255
+ /** 随 error 活动上送的 stderr 尾部上限。 */
256
+ const STDERR_TAIL_CAP = 2000;
257
+ /**
258
+ * 等待子进程结束。必须监听 error:spawn 失败(如 PATH 里没有该 runtime 的二进制)时
259
+ * Node 只发 error 不发 close——不监听会以未处理异常炸掉整个 daemon 进程,
260
+ * 且 close 永不触发导致本轮永久挂起。取先到的事件为准。
261
+ */
262
+ export function awaitExit(child) {
263
+ return new Promise((resolve) => {
264
+ let settled = false;
265
+ const settle = (r) => {
266
+ if (settled)
267
+ return;
268
+ settled = true;
269
+ resolve(r);
270
+ };
271
+ child.on("error", (e) => settle({ exitCode: -1, spawnError: e.message }));
272
+ child.on("close", (code) => settle({ exitCode: code ?? 0 }));
273
+ });
274
+ }
275
+ /**
276
+ * 进程退出 → 收尾活动。codex/kimi 非零退出必须显式报 error(它们失败时往往一条事件都没吐,
277
+ * 不报就会被 serve 的「本轮结束」伪装成成功);exitCode -1 是 awaitExit 的 spawn 失败哨兵,
278
+ * 任何 runtime 都报(spawn 失败连事件流都没有);kimi 正常退出补 done(其 stream 无轮次结束事件);
279
+ * codex 正常退出与 claude 均返回 null(终态由 turn.completed / result 事件负责)。
280
+ */
281
+ export function exitActivity(runtime, exitCode, stderrTail) {
282
+ if ((runtime === "codex" || runtime === "kimi" || exitCode === -1) && exitCode !== 0) {
283
+ return {
284
+ kind: "error",
285
+ label: "运行出错",
286
+ detail: `${runtime} exited with code ${exitCode}${stderrTail ? `: ${stderrTail}` : ""}`,
287
+ };
288
+ }
289
+ if (runtime === "kimi" && exitCode === 0)
290
+ return { kind: "done", label: "本轮结束" };
291
+ return null;
292
+ }
247
293
  function defaultPrint(a) {
248
294
  const icon = ICON[a.kind] ?? "·";
249
295
  const detail = a.detail ? ` ${a.detail.replace(/\s+/g, " ").slice(0, 120)}` : "";
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Claude Code runtime 适配:print + stream-json 模式,headless 驱动。
3
3
  */
4
- import { spawn } from "node:child_process";
4
+ // cross-spawn:win32 上 npm CLI 是 .cmd shim,node 原生 spawn 不带 shell 无法执行(ENOENT/EINVAL)
5
+ import spawn from "cross-spawn";
6
+ // Claude Code 原生 --effort 档位(claude 2.1.196 实测:--help 与非法值告警均枚举这五档)。
7
+ // 白名单外的值(含 "default" 与 codex 专属档)不传参 → 用 claude 自身默认,脏数据不影响启动。
8
+ export const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
5
9
  export function buildClaudeArgs(input) {
6
10
  const args = [
7
11
  "--print",
@@ -13,6 +17,9 @@ export function buildClaudeArgs(input) {
13
17
  ];
14
18
  if (input.model)
15
19
  args.push("--model", input.model);
20
+ if (input.reasoning && CLAUDE_EFFORT_LEVELS.includes(input.reasoning)) {
21
+ args.push("--effort", input.reasoning);
22
+ }
16
23
  if (input.sessionId) {
17
24
  args.push(input.resume ? "--resume" : "--session-id", input.sessionId);
18
25
  }
@@ -22,6 +29,7 @@ export function buildClaudeArgs(input) {
22
29
  return args;
23
30
  }
24
31
  export function spawnClaude(input) {
32
+ // stdio 固定 ignore/pipe/pipe,stdout/stderr 必为 Readable;cross-spawn 类型不带该细化,断言之
25
33
  return spawn(input.bin, buildClaudeArgs(input), {
26
34
  cwd: input.cwd,
27
35
  env: input.env,
@@ -1,17 +1,28 @@
1
1
  /**
2
2
  * Codex CLI runtime adapter: non-interactive exec mode with JSONL output.
3
3
  */
4
- import { spawn } from "node:child_process";
4
+ // cross-spawn:win32 上 npm CLI 是 .cmd shim,node 原生 spawn 不带 shell 无法执行(ENOENT/EINVAL)
5
+ import spawn from "cross-spawn";
6
+ // Codex CLI 原生 model_reasoning_effort 档位(codex 0.135.0 实测:非法值时 config 解析报错枚举这六档)。
7
+ // 注意:codex 对非法值是硬失败(进程直接退出),所以必须白名单过滤;白名单外(含 "default"、
8
+ // claude 专属的 "max")不传 → 用 codex 自身默认。
9
+ export const CODEX_EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh"];
5
10
  export function buildCodexArgs(input) {
6
- const args = ["exec", "--json"];
11
+ // agent 运行目录由 daemon 管理,不是 git 仓库;不带 --skip-git-repo-check 时 codex exec
12
+ // 会以 "Not inside a trusted directory" 秒退(且只报在本地 stderr),表现为 agent 静默不回复。
13
+ const args = ["exec", "--json", "--skip-git-repo-check"];
7
14
  if (input.model)
8
15
  args.push("--model", input.model);
16
+ if (input.reasoning && CODEX_EFFORT_LEVELS.includes(input.reasoning)) {
17
+ args.push("-c", `model_reasoning_effort=${input.reasoning}`);
18
+ }
9
19
  if (input.dangerous)
10
20
  args.push("--dangerously-bypass-approvals-and-sandbox");
11
21
  args.push(input.wakePrompt);
12
22
  return args;
13
23
  }
14
24
  export function spawnCodex(input) {
25
+ // stdio 固定 ignore/pipe/pipe,stdout/stderr 必为 Readable;cross-spawn 类型不带该细化,断言之
15
26
  return spawn(input.bin, buildCodexArgs(input), {
16
27
  cwd: input.cwd,
17
28
  env: input.env,
@@ -8,7 +8,11 @@
8
8
  * - 无 system prompt 注入参数 → 与 codex 同法:systemPrompt 拼在 wakePrompt 前。
9
9
  * - 鉴权是机器级的(`kimi login` 或 ~/.kimi-code/config.toml),不读 shell 环境变量。
10
10
  */
11
- import { spawn } from "node:child_process";
11
+ // cross-spawn:win32 上 npm CLI 是 .cmd shim,node 原生 spawn 不带 shell 无法执行(ENOENT/EINVAL)
12
+ import spawn from "cross-spawn";
13
+ // Kimi Code 思考强度档位(kimi-code 0.23.0 实测+源码):无 CLI 参数,
14
+ // 由 runner 经 KIMI_MODEL_THINKING_EFFORT env 注入;白名单外的值不注。
15
+ export const KIMI_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
12
16
  export function buildKimiArgs(input) {
13
17
  const args = ["--output-format", "stream-json"];
14
18
  if (input.model)
@@ -17,6 +21,7 @@ export function buildKimiArgs(input) {
17
21
  return args;
18
22
  }
19
23
  export function spawnKimi(input) {
24
+ // stdio 固定 ignore/pipe/pipe,stdout/stderr 必为 Readable;cross-spawn 类型不带该细化,断言之
20
25
  return spawn(input.bin, buildKimiArgs(input), {
21
26
  cwd: input.cwd,
22
27
  env: input.env,
package/dist/serve.js CHANGED
@@ -302,9 +302,20 @@ export function serve(config, opts = {}) {
302
302
  ...(result.usage.costUsd != null ? { cost_usd: result.usage.costUsd } : {}),
303
303
  } : {}),
304
304
  });
305
- reportActivity({ kind: "done", label: "本轮结束" });
306
- reportConsole({ stream: "result", text: "本轮结束" });
307
- log(`✅ agent=${msg.agentHandle} 本轮完成`);
305
+ if (result.exitCode === 0) {
306
+ reportActivity({ kind: "done", label: "本轮结束" });
307
+ reportConsole({ stream: "result", text: "● 本轮结束" });
308
+ log(`✅ agent=${msg.agentHandle} 本轮完成`);
309
+ }
310
+ else {
311
+ // 非零退出不能谎报「本轮结束」。runner 已对 codex/kimi 上报带 stderr 的 error 活动,
312
+ // 这里只兜底(如 claude 崩溃)并让终态落在 error 上。
313
+ if (!result.activities.some((a) => a.kind === "error")) {
314
+ reportActivity({ kind: "error", label: "运行出错", detail: `${result.runtime} 退出码 ${result.exitCode}` });
315
+ }
316
+ reportConsole({ stream: "error", text: `✖ 运行出错 (exit ${result.exitCode})` });
317
+ log(`❌ agent=${msg.agentHandle} 本轮失败 (exit ${result.exitCode})`);
318
+ }
308
319
  }
309
320
  catch (e) {
310
321
  log(`❌ runAgent 失败: ${e.message}`);
package/dist/workspace.js CHANGED
@@ -41,6 +41,8 @@ export async function prepareWorkspace(input) {
41
41
  const wrapper = join(crewDir, "crew");
42
42
  await writeFile(wrapper, `#!/bin/sh\nexec node ${JSON.stringify(input.cliPath)} "$@"\n`, "utf8");
43
43
  await chmod(wrapper, 0o755);
44
+ // win32 侧 wrapper:cmd/PowerShell 不认 shebang 脚本,并存 crew.cmd(unix 下无害不会被命中)
45
+ await writeFile(join(crewDir, "crew.cmd"), `@echo off\r\nnode ${JSON.stringify(input.cliPath)} %*\r\n`, "utf8");
44
46
  // 每个 agent 独立的 XDG 配置根:隔离第三方 CLI 凭证,避免 agent 之间互相串号
45
47
  const homeDir = join(dir, ".home");
46
48
  await mkdir(join(homeDir, ".config"), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -17,10 +17,12 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
+ "cross-spawn": "^7.0.6",
20
21
  "ws": "^8",
21
22
  "@nowcrew/cli": "^0.3.1"
22
23
  },
23
24
  "devDependencies": {
25
+ "@types/cross-spawn": "^6.0.6",
24
26
  "@types/node": "^22.0.0",
25
27
  "@types/ws": "^8",
26
28
  "tsx": "^4.19.0",