@nowcrew/daemon 0.4.3 → 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.
package/dist/config.js CHANGED
@@ -33,6 +33,6 @@ export function loadConfig(env = process.env) {
33
33
  dangerous: env.CREW_RUNTIME_SAFE !== "1", // 默认开启 (headless agent 在自有 workspace 内运行)
34
34
  resume: env.CREW_RESUME !== "off" && env.CREW_RESUME !== "0", // 默认开启;一键回退现状用 CREW_RESUME=off
35
35
  resumeWarmMs: env.CREW_RESUME_WARM_MS != null ? Number(env.CREW_RESUME_WARM_MS) : 3_600_000, // 默认 1h
36
- productName: env.CREW_PRODUCT_NAME ?? "OpenSlock",
36
+ productName: env.CREW_PRODUCT_NAME ?? "nowcrew",
37
37
  };
38
38
  }
@@ -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 {
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ import { detectDaemonLang, translateDaemon } from "./i18n.js";
11
11
  import { cliVersion, daemonVersion } from "./machine-info.js";
12
12
  import { runAgent } from "./runner.js";
13
13
  import { serve } from "./serve.js";
14
+ import { initSlog, flushSlog } from "./slog.js";
14
15
  async function main() {
15
16
  const lang = detectDaemonLang();
16
17
  const td = (message) => translateDaemon(lang, message);
@@ -64,6 +65,7 @@ async function main() {
64
65
  process.exit(2);
65
66
  }
66
67
  process.stdout.write(`\n🚀 ${td("Waking agent")} "${values.agent}" ${td("for channel")} ${values.channel}\n\n`);
68
+ initSlog(config.serverUrl, config.machineToken); // 一次性 run 模式也上报 SLS(runner 里的埋点生效)
67
69
  const result = await runAgent(config, {
68
70
  handle: values.agent,
69
71
  channelId: values.channel,
@@ -71,6 +73,7 @@ async function main() {
71
73
  ...(values.display ? { displayName: values.display } : {}),
72
74
  });
73
75
  process.stdout.write(`\n— ${td("agent exited")} (code ${result.exitCode}), ${td("activities")}: ${result.activities.length} —\n`);
76
+ await flushSlog();
74
77
  process.exit(result.exitCode);
75
78
  }
76
79
  main().catch((e) => {
@@ -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/prompt.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * 系统提示词与唤醒提示词构造(OpenSlock 自有设计)。
2
+ * 系统提示词与唤醒提示词构造(nowcrew 自有设计)。
3
3
  *
4
- * 描述的是 OpenSlock 自身的 crew CLI 命令面与运行约定:crew-only 通信、一命令一调用、
4
+ * 描述的是 nowcrew 自身的 crew CLI 命令面与运行约定:crew-only 通信、一命令一调用、
5
5
  * claim-before-work、freshness/draft、"做完所有事再停"、inbox notice 语义、
6
6
  * 分层记忆与压缩安全、协作礼仪。措辞为本项目原创。
7
7
  */
8
8
  export function buildSystemPrompt(ctx) {
9
- const product = ctx.productName ?? "OpenSlock";
9
+ const product = ctx.productName ?? "nowcrew";
10
10
  return `你是 "${ctx.handle}",${product}(一个让人类与 AI agent 协作的共享工作区)中的 AI 成员。${product} 为可能运行在不同机器上的人与 agent 提供共享的消息服务。
11
11
 
12
12
  ## 你是谁
@@ -82,6 +82,7 @@ CRITICAL 规则:
82
82
 
83
83
  ## 沟通风格
84
84
  用户看不到你的内部推理,所以:收到任务先确认并简述计划;多步工作发简短进度("正在做 2/3…");完成后总结结果。每条一两句,别刷屏。
85
+ - 完成汇报要直接说“已在当前线程汇报”或“已在任务线程汇报”,并说明“task #N 已置为 in_review”等事实。不要写“通过 ${product} 线程汇报”这类产品名+线程的生硬说法。
85
86
 
86
87
  ## Workspace 与分层记忆(CRITICAL — 索引+按需,配合上面的并行规则)
87
88
  你的持久记忆在 \`$CREW_HOME\`(跨你所有任务共享),分三层:
package/dist/runner.js CHANGED
@@ -9,10 +9,11 @@ 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";
16
+ import { dslog } from "./slog.js";
16
17
  // 注入提示词的 MEMORY.md 上限:只喂索引/角色,避免把膨胀的记忆全塞进上下文。
17
18
  const MEMORY_INJECT_CAP = 6000;
18
19
  const ICON = {
@@ -45,9 +46,22 @@ onConsole = () => { }) {
45
46
  // 只决定「是否续用」:既有会话(sessionResume)且仍在缓存窗口内 → --resume;否则冷启动。
46
47
  const resuming = supportsNativeResume && ws.sessionResume && pickResumeId(prior, Date.now(), config.resumeWarmMs, currentModel) != null;
47
48
  // 既有会话但本轮不续用 → 轮换出新 uuid 冷启动(避免 --session-id 撞已存在会话)。
48
- const launchSessionId = ws.agentSessionId && ws.sessionResume && !resuming
49
- ? await rotateAgentSession(ws.runDir)
50
- : ws.agentSessionId;
49
+ const rotated = !!(ws.agentSessionId && ws.sessionResume && !resuming);
50
+ const launchSessionId = rotated ? await rotateAgentSession(ws.runDir) : ws.agentSessionId;
51
+ // resume 决策是「会话为什么没续上/为什么重开」的直接证据,把判定依据全量留痕
52
+ dslog("session.resume_decision", resuming ? "续用上轮会话 (--resume)" : "冷启动新会话", {
53
+ run_id: input.runId, agent_handle: input.handle, channel_id: input.channelId,
54
+ task_key: input.taskKey, runtime, model: currentModel,
55
+ resuming, rotated, session_id: launchSessionId,
56
+ prior_session_id: prior?.sessionId ?? null,
57
+ prior_age_ms: prior ? Date.now() - prior.lastRunAt : null,
58
+ warm_ms: config.resumeWarmMs,
59
+ decision_reason: !supportsNativeResume ? "runtime_no_resume"
60
+ : !config.resume ? "resume_disabled"
61
+ : !ws.sessionResume ? "first_run"
62
+ : resuming ? "warm_resume"
63
+ : "warm_window_expired_or_model_changed",
64
+ });
51
65
  const systemPrompt = buildSystemPrompt({
52
66
  handle: input.handle,
53
67
  channelId: input.channelId,
@@ -63,8 +77,9 @@ onConsole = () => { }) {
63
77
  });
64
78
  await writeFile(ws.systemPromptPath, systemPrompt, "utf8");
65
79
  // 3) spawn runtime,注入 PATH(crew wrapper)、凭证 env、以及 agent 运行时配置
66
- // provider=custom → BYOC(ANTHROPIC_BASE_URL/API_KEY);reasoning → 思考预算;model → --model
67
- 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 适配器里,白名单外不传)。
68
83
  // resume 时提示 agent 上下文已在,无需从头重读频道(配合不注入 work-log,进一步省 token)。
69
84
  const baseWake = input.wake ?? buildWakePrompt(input.channelId);
70
85
  const wakePrompt = resuming
@@ -73,7 +88,7 @@ onConsole = () => { }) {
73
88
  const childEnv = {
74
89
  ...process.env,
75
90
  // 用户自定义 env(表单 ENVIRONMENT VARIABLES):先合入,可覆盖继承的 shell 环境;
76
- // 但 PATH/CREW_*/XDG_* 及下方由表单生成的值(ANTHROPIC_*/思考预算)在其后合入,始终以系统为准。
91
+ // 但 PATH/CREW_*/XDG_* 及下方由表单生成的值(ANTHROPIC_*/思考强度)在其后合入,始终以系统为准。
77
92
  ...sanitizeEnvVars(cfg.envVars),
78
93
  PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
79
94
  CREW_SERVER_URL: config.serverUrl,
@@ -95,10 +110,10 @@ onConsole = () => { }) {
95
110
  // provider custom = BYOC:为该 agent 单独设置 Anthropic 端点/密钥
96
111
  ...(cfg.provider === "custom" && cfg.providerBaseUrl ? { ANTHROPIC_BASE_URL: cfg.providerBaseUrl } : {}),
97
112
  ...(cfg.provider === "custom" && cfg.providerApiKey ? { ANTHROPIC_API_KEY: cfg.providerApiKey } : {}),
98
- // reasoning → 思考预算 (claude MAX_THINKING_TOKENS;kimi 读 KIMI_MODEL_THINKING_EFFORT,
99
- // 值域 low/medium/high/xhigh/max 与本配置兼容,对其它 runtime 无害)
100
- ...(cfg.reasoning && cfg.reasoning !== "default" && REASONING_TOKENS[cfg.reasoning]
101
- ? { 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 }
102
117
  : {}),
103
118
  // fast 模式 → 透传给 runtime(best-effort,供 wrapper/runtime 读取)
104
119
  ...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
@@ -111,6 +126,7 @@ onConsole = () => { }) {
111
126
  wakePrompt,
112
127
  dangerous: config.dangerous,
113
128
  ...(currentModel ? { model: currentModel } : {}),
129
+ ...(cfg.reasoning ? { reasoning: cfg.reasoning } : {}),
114
130
  // 一线程一会话(唯一会话机制):首轮/冷启动 --session-id 固定 uuid,warm 续轮 --resume 续上。
115
131
  ...(launchSessionId ? { sessionId: launchSessionId, resume: resuming } : {}),
116
132
  env: childEnv,
@@ -122,6 +138,7 @@ onConsole = () => { }) {
122
138
  wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
123
139
  dangerous: config.dangerous,
124
140
  ...(currentModel ? { model: currentModel } : {}),
141
+ ...(cfg.reasoning ? { reasoning: cfg.reasoning } : {}),
125
142
  env: childEnv,
126
143
  })
127
144
  : runtime === "kimi"
@@ -137,6 +154,12 @@ onConsole = () => { }) {
137
154
  : (() => {
138
155
  throw new Error(`unsupported runtime: ${runtime}`);
139
156
  })();
157
+ // 记下 OS 进程号:排查「进程被谁杀的/是否 OOM」时可与系统日志对齐
158
+ dslog("run.spawned", `${runtime} 进程已拉起 (pid ${child.pid})`, {
159
+ run_id: input.runId, agent_handle: input.handle, channel_id: input.channelId,
160
+ task_key: input.taskKey, runtime, model: currentModel,
161
+ os_pid: child.pid ?? null, session_id: launchSessionId, resume: resuming,
162
+ });
140
163
  // 4) 逐行解析 stdout → 归一化 → 回调;顺带抓 session_id(记 lastRunAt 供 warm-window)+ token usage(度量)
141
164
  const activities = [];
142
165
  // 身份是本轮下发的 launchSessionId;仍兜底采 claude 自报的 session_id(理应一致)。
@@ -176,17 +199,22 @@ onConsole = () => { }) {
176
199
  for (const c of toConsoleLines(evt))
177
200
  onConsole(c);
178
201
  });
179
- child.stderr.on("data", (d) => process.stderr.write(d));
180
- const exitCode = await new Promise((resolve) => {
181
- 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);
182
208
  });
183
- // kimi stream-json 没有轮次结束事件(进程退出即结束),补一个 done/error 活动对齐前端状态。
184
- if (runtime === "kimi") {
185
- const a = exitCode === 0
186
- ? { kind: "done", label: "本轮结束" }
187
- : { kind: "error", label: "运行出错", detail: `kimi exited with code ${exitCode}` };
188
- activities.push(a);
189
- 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}` });
190
218
  }
191
219
  if ((runtime === "codex" || runtime === "kimi") && exitCode === 0 && !sentViaCrew && finalText) {
192
220
  // force:兜底回帖锚定本轮触发消息的线程,语义上必须送达;不 force 时 agent(-p 单发不跑
@@ -222,7 +250,45 @@ onConsole = () => { }) {
222
250
  process.stdout.write(`📊 tokens: in=${u.inputTokens} out=${u.outputTokens} cache_read=${u.cacheReadTokens} cache_create=${u.cacheCreationTokens}` +
223
251
  `${u.costUsd != null ? ` cost=$${u.costUsd.toFixed(4)}` : ""} ${resuming ? "(resumed)" : "(fresh)"}\n`);
224
252
  }
225
- return { exitCode, activities, model: observedModel, runtime, resumed: resuming, ...(usage ? { usage } : {}) };
253
+ return { exitCode, activities, model: observedModel, runtime, resumed: resuming, sessionId, ...(usage ? { usage } : {}) };
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;
226
292
  }
227
293
  function defaultPrint(a) {
228
294
  const icon = ICON[a.kind] ?? "·";
@@ -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
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { WebSocket } from "ws";
6
6
  import { join } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { initSlog, dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
7
9
  import { runAgent } from "./runner.js";
8
10
  import { collectMachineHello } from "./machine-info.js";
9
11
  import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
@@ -23,6 +25,9 @@ export function serve(config, opts = {}) {
23
25
  let ws = null;
24
26
  let backoff = 1000;
25
27
  const maxBackoff = opts.maxBackoffMs ?? 30_000;
28
+ let connectedAt = 0; // 本次 WS 连接建立时刻(断开日志算在线时长用)
29
+ initSlog(config.serverUrl, config.machineToken);
30
+ dslog("daemon.start", "daemon 常驻模式启动", { server_url: config.serverUrl, runtime: config.runtimeBin });
26
31
  // 并行调度:同一 agent 可并行处理多个【不同任务】(线程/频道),每任务隔离 cwd+work-log。
27
32
  // - running:正在跑的「agent:任务」去重键(同一任务重复唤醒才跳过)。
28
33
  // - agentSlots:每 agent 当前并行数;超过 MAX_PARALLEL 的进 FIFO 队列(不丢)。
@@ -56,7 +61,11 @@ export function serve(config, opts = {}) {
56
61
  ws = new WebSocket(wsUrl);
57
62
  ws.on("open", () => {
58
63
  backoff = 1000;
64
+ connectedAt = Date.now();
59
65
  log(`🔌 已连接控制面 ${config.serverUrl}`);
66
+ dslog("daemon.ws_open", "已连接控制面", { server_url: config.serverUrl });
67
+ // 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
68
+ void drainSpool();
60
69
  // 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
61
70
  void collectMachineHello(config.agentsRoot)
62
71
  .then((hello) => {
@@ -81,10 +90,17 @@ export function serve(config, opts = {}) {
81
90
  // 库已重置)。不能静默丢弃这帧——否则只表现为神秘的「每 1s 重连」循环。打印可执行
82
91
  // 提示,并把退避拉满,避免无意义高频重连刷屏 server(凭证失配不会靠重试自愈,
83
92
  // 需在 NowCrew 重新 Add Computer 拿新连接命令)。
93
+ if (msg.type === "ready") {
94
+ // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
95
+ const r = msg;
96
+ setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
97
+ return;
98
+ }
84
99
  if (msg.type === "error") {
85
100
  if (msg.code === "UNAUTHENTICATED") {
86
101
  log(`🛑 控制面拒绝鉴权:机器凭证无效或已吊销 (UNAUTHENTICATED)。`);
87
102
  log(` 请在 NowCrew 重新 "Add Computer" 获取新的连接命令,再到本机重跑(当前 --api-key 已失效)。`);
103
+ dslog("daemon.ws_auth_rejected", "控制面拒绝鉴权:机器凭证无效或已吊销", { level: "ERROR" });
88
104
  backoff = maxBackoff; // 退避到最大,停止每秒重连刷屏
89
105
  }
90
106
  return;
@@ -140,16 +156,31 @@ export function serve(config, opts = {}) {
140
156
  const threadId = msg.wake?.threadId;
141
157
  const taskKey = threadId ?? msg.channelId;
142
158
  const key = `${msg.agentHandle}:${taskKey}`;
159
+ // run_id 贯穿本轮全链路(wake→start→resume 决策→end),SLS 按 run_id 一查即得单轮时间线
160
+ const runId = randomUUID();
161
+ const runKeys = {
162
+ run_id: runId, agent_handle: msg.agentHandle, channel_id: msg.channelId,
163
+ thread_id: threadId ?? null, task_key: taskKey,
164
+ };
165
+ dslog("run.wake_received", `收到唤醒 ${msg.agentHandle}`, {
166
+ ...runKeys, reason: msg.reason ?? "", sender: msg.wake?.senderHandle,
167
+ content_preview: (msg.wake?.content ?? "").replace(/\s+/g, " ").slice(0, 120),
168
+ });
143
169
  if (running.has(key)) {
144
170
  log(`↩︎ 跳过(该任务已在运行): ${key}`);
171
+ dslog("run.dedupe_skip", "跳过唤醒:该任务已在运行", { level: "WARN", ...runKeys });
145
172
  return;
146
173
  }
147
174
  running.add(key);
148
175
  // 并行槽:同 agent 超过 MAX_PARALLEL 个任务时在此排队(不丢),有空位再跑。
176
+ const slotWaitStart = Date.now();
149
177
  await acquireSlot(msg.agentHandle);
178
+ const queueMs = Date.now() - slotWaitStart;
150
179
  const threadLabel = threadId ?? null;
151
180
  const from = msg.wake?.senderHandle ?? "?";
152
181
  const incoming = msg.wake?.content ?? "";
182
+ dslog("run.start", `开始运行 ${msg.agentHandle}`, { ...runKeys, queue_ms: queueMs });
183
+ const runStartedAt = Date.now();
153
184
  log(`\n${"─".repeat(56)}`);
154
185
  log(`🔔 唤醒 agent=${msg.agentHandle} reason=${msg.reason ?? "?"}`);
155
186
  log(` channel = ${msg.channelId}`);
@@ -226,6 +257,7 @@ export function serve(config, opts = {}) {
226
257
  handle: msg.agentHandle,
227
258
  channelId: msg.channelId,
228
259
  taskKey, // 每任务隔离 cwd + work-log(并行不冲突)
260
+ runId, // 贯穿 SLS 日志的单轮关联键
229
261
  // 唤醒锚点是具体消息(非纯频道唤醒)时,把它透传下去,供 `crew task create` 锚定到该消息。
230
262
  ...(threadId ? { wakeMessageId: threadId } : {}),
231
263
  ...(msg.wake?.content ? { wake: `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 ${readCmd} 读${threadId ? "本线程" : "频道"}后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}` } : {}),
@@ -252,16 +284,50 @@ export function serve(config, opts = {}) {
252
284
  }
253
285
  catch { /* ws 非 OPEN,忽略(用量非关键路径,丢一轮不阻塞) */ }
254
286
  }
255
- reportActivity({ kind: "done", label: "本轮结束" });
256
- reportConsole({ stream: "result", text: "● 本轮结束" });
257
- log(`✅ agent=${msg.agentHandle} 本轮完成`);
287
+ // run.end 是排查「任务没跑完就本轮结束」的核心证据:退出码 + 时长 + 最后活动 +
288
+ // 是否 resume + 用量。exit_code!=0 或时长异常短都值得追。
289
+ const lastActivity = result.activities.length
290
+ ? result.activities[result.activities.length - 1].kind
291
+ : null;
292
+ dslog("run.end", `本轮结束 ${msg.agentHandle} (exit=${result.exitCode})`, {
293
+ ...runKeys,
294
+ level: result.exitCode === 0 ? "INFO" : "ERROR",
295
+ exit_code: result.exitCode, duration_ms: Date.now() - runStartedAt,
296
+ runtime: result.runtime, model: result.model, resumed: result.resumed,
297
+ session_id: result.sessionId,
298
+ activity_count: result.activities.length, last_activity: lastActivity,
299
+ ...(result.usage ? {
300
+ tokens_input: result.usage.inputTokens, tokens_output: result.usage.outputTokens,
301
+ cache_read: result.usage.cacheReadTokens, cache_creation: result.usage.cacheCreationTokens,
302
+ ...(result.usage.costUsd != null ? { cost_usd: result.usage.costUsd } : {}),
303
+ } : {}),
304
+ });
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
+ }
258
319
  }
259
320
  catch (e) {
260
321
  log(`❌ runAgent 失败: ${e.message}`);
322
+ dslog("run.error", `runAgent 失败: ${e.message}`, {
323
+ level: "ERROR", ...runKeys, duration_ms: Date.now() - runStartedAt,
324
+ error_message: e.message, error_stack: e.stack,
325
+ });
261
326
  }
262
327
  finally {
263
328
  running.delete(key);
264
329
  releaseSlot(msg.agentHandle);
330
+ void flushSlog(); // 每轮收尾冲一次,保证 run.end 尽快可查
265
331
  }
266
332
  });
267
333
  ws.on("close", (code) => {
@@ -275,6 +341,14 @@ export function serve(config, opts = {}) {
275
341
  log(` 请在 NowCrew 重新 "Add Computer" 获取新连接命令再重跑(当前 --api-key 已失效)。`);
276
342
  }
277
343
  log(`🔁 控制面断开,${Math.round(backoff / 1000)}s 后重连`);
344
+ // 此刻 server 大概率不可达 → 这条会落 spool,重连 drainSpool 时补传;
345
+ // ts 是现在(断开时刻),排查「daemon 为什么断」以它对齐 server 侧 machine_disconnected。
346
+ dslog("daemon.ws_close", `控制面断开 (code ${code})`, {
347
+ level: "WARN", close_code: code, backoff_ms: backoff,
348
+ online_ms: connectedAt ? Date.now() - connectedAt : null,
349
+ running_tasks: [...running].join(","),
350
+ });
351
+ void flushSlog();
278
352
  setTimeout(connect, backoff);
279
353
  backoff = Math.min(backoff * 2, maxBackoff);
280
354
  });
package/dist/slog.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * daemon 侧 SLS 日志客户端 —— 透过 server 的 POST /ingest/logs 上报(daemon 不直连 SLS)。
3
+ *
4
+ * 断线补传(关键设计):
5
+ * - 每条日志的 ts = 事件**实际发生时间**(epoch ms)。server 把它写成 SLS __time__,
6
+ * 所以补传的日志仍落在正确时间点,时间线不乱;补传条目带 spooled=true + reported_at 归因。
7
+ * - 上报失败(server 不可达/断网)→ 落本地 spool(~/.crew/logs/sls-spool/*.jsonl);
8
+ * 下次 WS 重连成功(drainSpool)时补传。「daemon 为什么断开」的现场就靠这批日志还原。
9
+ * - 进程退出(SIGINT/SIGTERM/exit)→ 残余队列同步落 spool,下次启动补传。
10
+ *
11
+ * 永不抛错、永不阻塞业务;未 init 时所有调用是 no-op(兼容单测/一次性 run 模式无凭证场景)。
12
+ */
13
+ import { hostname } from "node:os";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
17
+ const FLUSH_INTERVAL_MS = 1500;
18
+ const BATCH_SIZE = 50;
19
+ const SPOOL_MAX_FILES = 50; // spool 目录文件数上限,超出丢最旧(日志非关键数据,防无限膨胀)
20
+ const SPOOL_DRAIN_CAP = 1000; // 单次补传条数上限
21
+ let cfg = null;
22
+ let defaults = {};
23
+ let queue = [];
24
+ let timer = null;
25
+ let flushing = false;
26
+ let lastErrorAt = 0;
27
+ function spoolDir() {
28
+ return process.env.CREW_SLS_SPOOL_DIR ?? join(homedir(), ".crew", "logs", "sls-spool");
29
+ }
30
+ /** serve/run 启动时调用一次;disabled(CREW_SLS_LOG=off)则保持 no-op。 */
31
+ export function initSlog(serverUrl, machineToken) {
32
+ if (process.env.CREW_SLS_LOG === "off" || process.env.CREW_SLS_LOG === "0")
33
+ return;
34
+ cfg = { serverUrl: serverUrl.replace(/\/+$/, ""), token: machineToken };
35
+ defaults = { host: hostname(), pid: process.pid };
36
+ // 退出兜底:残余队列同步落 spool(exit 回调只能做同步工作,append 正合适)
37
+ process.once("exit", () => spoolRemainingSync());
38
+ for (const sig of ["SIGINT", "SIGTERM"]) {
39
+ process.once(sig, () => {
40
+ spoolRemainingSync();
41
+ process.exit(sig === "SIGINT" ? 130 : 143);
42
+ });
43
+ }
44
+ }
45
+ /** 追加默认关联字段(如 ready 帧下发的 machine_id),之后每条日志自动带上。 */
46
+ export function setSlogDefaults(fields) {
47
+ defaults = { ...defaults, ...fields };
48
+ }
49
+ /** 记一条结构化日志(异步批量上报;失败自动落 spool)。 */
50
+ export function dslog(eventType, message, fields = {}) {
51
+ if (!cfg)
52
+ return;
53
+ const { level, ...rest } = fields;
54
+ queue.push({
55
+ ts: Date.now(),
56
+ level: level ?? (eventType.includes("error") || eventType.includes("failed") ? "ERROR" : "INFO"),
57
+ event_type: eventType,
58
+ message,
59
+ fields: compact({ ...defaults, ...rest }),
60
+ });
61
+ if (queue.length >= BATCH_SIZE) {
62
+ void flushSlog();
63
+ return;
64
+ }
65
+ if (!timer) {
66
+ timer = setTimeout(() => void flushSlog(), FLUSH_INTERVAL_MS);
67
+ timer.unref?.();
68
+ }
69
+ }
70
+ /** 把内存队列冲到 server;失败整批落 spool(保 ts 不丢现场)。 */
71
+ export async function flushSlog() {
72
+ if (timer) {
73
+ clearTimeout(timer);
74
+ timer = null;
75
+ }
76
+ if (!cfg || flushing || queue.length === 0)
77
+ return;
78
+ flushing = true;
79
+ const batch = queue;
80
+ queue = [];
81
+ try {
82
+ await post(batch);
83
+ }
84
+ catch (e) {
85
+ appendSpool(batch);
86
+ warnThrottled(`SLS 日志上报失败,已落本地 spool(${batch.length} 条): ${e.message}`);
87
+ }
88
+ finally {
89
+ flushing = false;
90
+ }
91
+ }
92
+ /**
93
+ * 补传本地 spool(WS 重连成功后调用):上次断连/退出前落盘的现场日志,
94
+ * 此刻才有机会送出。条目加 spooled=true,ts 仍是当时的发生时间。
95
+ */
96
+ export async function drainSpool() {
97
+ if (!cfg)
98
+ return;
99
+ const dir = spoolDir();
100
+ let files;
101
+ try {
102
+ files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
103
+ }
104
+ catch {
105
+ return; // 目录不存在 = 无积压
106
+ }
107
+ if (files.length === 0)
108
+ return;
109
+ const entries = [];
110
+ const consumed = [];
111
+ for (const f of files) {
112
+ if (entries.length >= SPOOL_DRAIN_CAP)
113
+ break;
114
+ const path = join(dir, f);
115
+ try {
116
+ const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
117
+ for (const line of lines) {
118
+ try {
119
+ const e = JSON.parse(line);
120
+ if (typeof e.ts === "number" && typeof e.event_type === "string") {
121
+ entries.push({ ...e, fields: { ...(e.fields ?? {}), spooled: true } });
122
+ }
123
+ }
124
+ catch { /* 坏行跳过 */ }
125
+ }
126
+ consumed.push(path);
127
+ }
128
+ catch { /* 读失败跳过该文件 */ }
129
+ }
130
+ if (entries.length === 0) {
131
+ for (const p of consumed)
132
+ try {
133
+ unlinkSync(p);
134
+ }
135
+ catch { /* 忽略 */ }
136
+ return;
137
+ }
138
+ try {
139
+ for (let i = 0; i < entries.length; i += 100) {
140
+ await post(entries.slice(i, i + 100));
141
+ }
142
+ for (const p of consumed)
143
+ try {
144
+ unlinkSync(p);
145
+ }
146
+ catch { /* 忽略 */ }
147
+ process.stdout.write(`📮 已补传离线期间的 ${entries.length} 条 SLS 日志\n`);
148
+ }
149
+ catch (e) {
150
+ warnThrottled(`spool 补传失败,留待下次重连: ${e.message}`); // 文件保留,下次再试
151
+ }
152
+ }
153
+ // ── 内部 ─────────────────────────────────────────────────────────
154
+ async function post(entries) {
155
+ const res = await fetch(`${cfg.serverUrl}/ingest/logs`, {
156
+ method: "POST",
157
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
158
+ body: JSON.stringify({
159
+ entries: entries.map((e) => ({
160
+ ts: e.ts, level: e.level, event_type: e.event_type, message: e.message, fields: e.fields,
161
+ })),
162
+ }),
163
+ signal: AbortSignal.timeout(8000),
164
+ });
165
+ if (!res.ok)
166
+ throw new Error(`HTTP ${res.status}`);
167
+ }
168
+ function appendSpool(entries) {
169
+ try {
170
+ const dir = spoolDir();
171
+ if (!existsSync(dir))
172
+ mkdirSync(dir, { recursive: true });
173
+ rotateSpool(dir);
174
+ const file = join(dir, `daemon-${Date.now()}-${process.pid}.jsonl`);
175
+ appendFileSync(file, entries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
176
+ }
177
+ catch { /* spool 也失败(磁盘只读等):放弃这批,不能影响业务 */ }
178
+ }
179
+ /** 目录内文件超上限时删最旧,防止长期断网把磁盘写满。 */
180
+ function rotateSpool(dir) {
181
+ try {
182
+ const files = readdirSync(dir)
183
+ .filter((f) => f.endsWith(".jsonl"))
184
+ .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
185
+ .sort((a, b) => a.mtime - b.mtime);
186
+ for (const { f } of files.slice(0, Math.max(0, files.length - SPOOL_MAX_FILES + 1))) {
187
+ unlinkSync(join(dir, f));
188
+ }
189
+ }
190
+ catch { /* 忽略 */ }
191
+ }
192
+ function spoolRemainingSync() {
193
+ if (queue.length === 0)
194
+ return;
195
+ const batch = queue;
196
+ queue = [];
197
+ appendSpool(batch);
198
+ }
199
+ function warnThrottled(msg) {
200
+ const now = Date.now();
201
+ if (now - lastErrorAt > 60_000) {
202
+ lastErrorAt = now;
203
+ process.stderr.write(`[slog] ${msg}\n`);
204
+ }
205
+ }
206
+ function compact(fields) {
207
+ const out = {};
208
+ for (const [k, v] of Object.entries(fields)) {
209
+ if (v === undefined)
210
+ continue;
211
+ out[k] = typeof v === "string" && v.length > 4096 ? `${v.slice(0, 4096)}…(truncated)` : v;
212
+ }
213
+ return out;
214
+ }
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.3",
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
- "@nowcrew/cli": "^0.3.0"
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",