@nowcrew/daemon 0.5.17 → 0.5.19

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.
@@ -4,6 +4,9 @@ import { Readable, Writable } from "node:stream";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import spawn from "cross-spawn";
6
6
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from "@agentclientprotocol/sdk";
7
+ import { augmentedPath } from "../runtime-path.js";
8
+ import { startFirstProgressWatchdog } from "./progress-watchdog.js";
9
+ import { assertKimiLegacyPromptFits, buildKimiArgs } from "./kimi.js";
7
10
  const ERROR_MESSAGE_CAP = 2_000;
8
11
  const PROBE_TIMEOUT_MS = 5_000;
9
12
  function jsonLine(event) {
@@ -60,6 +63,17 @@ function safeErrorMessage(error, prompt) {
60
63
  const redacted = prompt && raw.includes(prompt) ? raw.replaceAll(prompt, "[prompt redacted]") : raw;
61
64
  return redacted.slice(0, ERROR_MESSAGE_CAP);
62
65
  }
66
+ export function isKimiAuthenticationRequired(error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ return /\bauthentication required\b/i.test(message);
69
+ }
70
+ export function kimiResumeMethod(capabilities) {
71
+ if (capabilities?.sessionCapabilities?.resume != null)
72
+ return "resume";
73
+ if (capabilities?.loadSession)
74
+ return "load";
75
+ return null;
76
+ }
63
77
  /** Full access may approve an operation, but it must never fabricate an answer to an agent question. */
64
78
  export function selectKimiPermission(params) {
65
79
  const allowOnce = params.options.filter((option) => option.kind === "allow_once");
@@ -100,11 +114,12 @@ async function stopChild(child) {
100
114
  await closed;
101
115
  }
102
116
  }
103
- /** A help banner is insufficient: Kimi's authenticate RPC re-checks its token without creating a session. */
117
+ /** Probe the ACP transport without starting a session or forcing an optional interactive login flow. */
104
118
  export async function probeKimiAcp(options, spawnProcess = spawn) {
105
119
  const child = spawnProcess(options.bin, ["acp"], {
106
120
  cwd: process.cwd(),
107
- env: process.env,
121
+ // probe 在 daemon 自身 PATH 下运行,补上用户级 CLI 目录,与 which 探测保持一致。
122
+ env: { ...process.env, PATH: augmentedPath() },
108
123
  stdio: ["pipe", "pipe", "pipe"],
109
124
  });
110
125
  if (child.stdin === null || child.stdout === null || child.stderr === null)
@@ -119,15 +134,11 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
119
134
  try {
120
135
  const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
121
136
  const connected = app.connectWith(stream, async (context) => {
122
- const initialized = await context.request(methods.agent.initialize, {
137
+ await context.request(methods.agent.initialize, {
123
138
  protocolVersion: PROTOCOL_VERSION,
124
139
  clientCapabilities: {},
125
140
  clientInfo: { name: "nowcrew-daemon", version: "1" },
126
141
  });
127
- const authMethod = initialized.authMethods?.[0];
128
- if (authMethod !== undefined) {
129
- await context.request(methods.agent.authenticate, { methodId: authMethod.id });
130
- }
131
142
  return true;
132
143
  });
133
144
  const result = await Promise.race([
@@ -157,12 +168,15 @@ export async function runKimiAcp(options) {
157
168
  env: process.env,
158
169
  stdio: ["pipe", "pipe", "pipe"],
159
170
  });
171
+ let runtimeChild = child;
160
172
  if (child.stdin === null || child.stdout === null || child.stderr === null) {
161
173
  throw new Error("Kimi ACP process did not expose stdio");
162
174
  }
163
175
  child.stderr.pipe(process.stderr, { end: false });
164
176
  let context = null;
165
177
  let sessionId = null;
178
+ let acpSemanticProgress = false;
179
+ let progressTimedOut = false;
166
180
  let cancelling = false;
167
181
  const cancel = async () => {
168
182
  if (cancelling)
@@ -171,7 +185,7 @@ export async function runKimiAcp(options) {
171
185
  if (context !== null && sessionId !== null) {
172
186
  await context.notify(methods.agent.session.cancel, { sessionId }).catch(() => undefined);
173
187
  }
174
- await stopChild(child);
188
+ await stopChild(runtimeChild);
175
189
  };
176
190
  const onSignal = () => {
177
191
  void cancel().finally(() => process.exit(130));
@@ -181,9 +195,13 @@ export async function runKimiAcp(options) {
181
195
  const app = client({ name: "nowcrew-daemon-kimi" })
182
196
  .onRequest(methods.client.session.requestPermission, ({ params }) => selectKimiPermission(params))
183
197
  .onNotification(methods.client.session.update, async ({ params }) => {
198
+ acpSemanticProgress = true;
199
+ firstProgress.observe();
184
200
  for (const event of mapKimiAcpUpdate(params.update))
185
201
  await jsonLine(event);
186
202
  });
203
+ let firstProgress = startFirstProgressWatchdog(() => undefined);
204
+ firstProgress.stop();
187
205
  try {
188
206
  const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
189
207
  const result = await app.connectWith(stream, async (nextContext) => {
@@ -196,15 +214,35 @@ export async function runKimiAcp(options) {
196
214
  if (process.env.CREW_KIMI_ACP_DEBUG === "1") {
197
215
  process.stderr.write(`Kimi ACP auth methods: ${JSON.stringify(initialized.authMethods ?? [])}\n`);
198
216
  }
199
- const authMethod = initialized.authMethods?.[0];
200
- if (authMethod !== undefined) {
201
- await nextContext.request(methods.agent.authenticate, { methodId: authMethod.id });
217
+ if (options.resume && options.sessionId) {
218
+ 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
+ });
225
+ }
226
+ else if (resumeMethod === "load") {
227
+ await nextContext.request(methods.agent.session.load, {
228
+ sessionId: options.sessionId,
229
+ cwd: process.cwd(),
230
+ mcpServers: [],
231
+ });
232
+ }
233
+ else {
234
+ throw new Error("Kimi ACP does not advertise session resume support");
235
+ }
236
+ sessionId = options.sessionId;
202
237
  }
203
- const session = await nextContext.request(methods.agent.session.new, {
204
- cwd: process.cwd(),
205
- mcpServers: [],
206
- });
207
- sessionId = session.sessionId;
238
+ else {
239
+ const session = await nextContext.request(methods.agent.session.new, {
240
+ cwd: process.cwd(),
241
+ mcpServers: [],
242
+ });
243
+ sessionId = session.sessionId;
244
+ }
245
+ await jsonLine({ type: "thread.started", thread_id: sessionId });
208
246
  if (options.model) {
209
247
  await nextContext.request(methods.agent.session.setConfigOption, {
210
248
  sessionId,
@@ -212,11 +250,20 @@ export async function runKimiAcp(options) {
212
250
  value: options.model,
213
251
  });
214
252
  }
253
+ firstProgress = startFirstProgressWatchdog(() => {
254
+ progressTimedOut = true;
255
+ void cancel();
256
+ });
215
257
  return nextContext.request(methods.agent.session.prompt, {
216
258
  sessionId,
217
259
  prompt: [{ type: "text", text: prompt }],
218
260
  });
219
261
  });
262
+ firstProgress.stop();
263
+ if (progressTimedOut) {
264
+ process.stderr.write("Kimi produced no semantic progress within the startup window\n");
265
+ return 1;
266
+ }
220
267
  const usage = result.usage;
221
268
  await jsonLine({
222
269
  type: "turn.completed",
@@ -233,10 +280,54 @@ export async function runKimiAcp(options) {
233
280
  return result.stopReason === "end_turn" ? 0 : result.stopReason === "cancelled" ? 130 : 1;
234
281
  }
235
282
  catch (error) {
283
+ if (progressTimedOut) {
284
+ process.stderr.write("Kimi produced no semantic progress within the startup window\n");
285
+ return 1;
286
+ }
287
+ if (!acpSemanticProgress && isKimiAuthenticationRequired(error)) {
288
+ firstProgress.stop();
289
+ await stopChild(child);
290
+ process.stderr.write("Kimi ACP requires account login; falling back to configured CLI provider transport\n");
291
+ assertKimiLegacyPromptFits(prompt);
292
+ const fallback = spawn(options.bin, buildKimiArgs({
293
+ wakePrompt: prompt,
294
+ effectivePermission: "full_access",
295
+ ...(options.model === undefined ? {} : { model: options.model }),
296
+ ...(options.resume && options.sessionId ? { sessionId: options.sessionId } : {}),
297
+ }), {
298
+ cwd: process.cwd(),
299
+ env: process.env,
300
+ stdio: ["ignore", "pipe", "pipe"],
301
+ });
302
+ runtimeChild = fallback;
303
+ if (fallback.stdout === null || fallback.stderr === null) {
304
+ throw new Error("Kimi CLI fallback did not expose output streams");
305
+ }
306
+ firstProgress = startFirstProgressWatchdog(() => {
307
+ progressTimedOut = true;
308
+ void stopChild(fallback);
309
+ });
310
+ fallback.stdout.on("data", () => firstProgress.observe());
311
+ fallback.stdout.pipe(process.stdout, { end: false });
312
+ fallback.stderr.pipe(process.stderr, { end: false });
313
+ const code = await new Promise((resolve, reject) => {
314
+ fallback.once("error", reject);
315
+ fallback.once("close", (exitCode, signal) => {
316
+ resolve(exitCode ?? (signal === null ? 1 : 128));
317
+ });
318
+ });
319
+ firstProgress.stop();
320
+ if (progressTimedOut) {
321
+ process.stderr.write("Kimi CLI fallback produced no output within the startup window\n");
322
+ return 1;
323
+ }
324
+ return code;
325
+ }
236
326
  process.stderr.write(`Kimi ACP execution failed: ${safeErrorMessage(error, prompt)}\n`);
237
327
  return 1;
238
328
  }
239
329
  finally {
330
+ firstProgress.stop();
240
331
  process.off("SIGTERM", onSignal);
241
332
  process.off("SIGINT", onSignal);
242
333
  await stopChild(child);
@@ -248,11 +339,20 @@ function optionsFromArgv(argv) {
248
339
  options: {
249
340
  bin: { type: "string" },
250
341
  model: { type: "string" },
342
+ session: { type: "string" },
343
+ resume: { type: "boolean", default: false },
251
344
  },
252
345
  });
253
346
  if (!values.bin)
254
347
  throw new Error("--bin is required");
255
- return { bin: values.bin, ...(values.model ? { model: values.model } : {}) };
348
+ if (values.resume && !values.session)
349
+ throw new Error("--resume requires --session");
350
+ return {
351
+ bin: values.bin,
352
+ ...(values.model ? { model: values.model } : {}),
353
+ ...(values.session ? { sessionId: values.session } : {}),
354
+ ...(values.resume ? { resume: true } : {}),
355
+ };
256
356
  }
257
357
  if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
258
358
  runKimiAcp(optionsFromArgv(process.argv.slice(2)))
@@ -29,6 +29,8 @@ export function buildKimiArgs(input) {
29
29
  const args = ["--output-format", "stream-json"];
30
30
  if (input.model)
31
31
  args.push("--model", input.model);
32
+ if (input.sessionId)
33
+ args.push("--session", input.sessionId);
32
34
  args.push("--prompt", input.wakePrompt);
33
35
  return args;
34
36
  }
@@ -0,0 +1,26 @@
1
+ export const DEFAULT_FIRST_PROGRESS_TIMEOUT_MS = 120_000;
2
+ /**
3
+ * Bound the silent gap after a protocol turn starts. Once any semantic notification arrives,
4
+ * the runtime's configured total timeout remains authoritative; long-running tools are not killed
5
+ * merely because they produce no output.
6
+ */
7
+ export function startFirstProgressWatchdog(onTimeout, timeoutMs = DEFAULT_FIRST_PROGRESS_TIMEOUT_MS) {
8
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
9
+ throw new RangeError("First-progress timeout must be a positive finite number");
10
+ }
11
+ let active = true;
12
+ const timer = setTimeout(() => {
13
+ if (!active)
14
+ return;
15
+ active = false;
16
+ onTimeout();
17
+ }, timeoutMs);
18
+ timer.unref?.();
19
+ const stop = () => {
20
+ if (!active)
21
+ return;
22
+ active = false;
23
+ clearTimeout(timer);
24
+ };
25
+ return { observe: stop, stop };
26
+ }
package/dist/serve.js CHANGED
@@ -237,9 +237,10 @@ export function serve(config, opts = {}) {
237
237
  initSlog(config.serverUrl, config.machineToken);
238
238
  dslog("daemon.start", "daemon 常驻模式启动", { server_url: config.serverUrl, runtime: config.runtimeBin });
239
239
  // 并行调度:同一 agent 可并行处理多个【不同任务】(线程/频道),每任务隔离 cwd+work-log。
240
- // - running:正在跑的「agent:任务」去重键(同一任务重复唤醒才跳过)
241
- // - agentSlots:每 agent 当前并行数;超过配置上限的进 FIFO 队列(不丢)。
240
+ // scheduled 重复 run 仍由 running 去重;普通同线程 wake 用 legacyTaskTails 串成 FIFO
241
+ // agent 超过并行上限的任务继续进入 sharedQueues(不丢)。
242
242
  const running = new Set();
243
+ const legacyTaskTails = new Map();
243
244
  const acquireSlot = async (handle) => {
244
245
  await reserveSharedSlot(handle, "legacy").ready;
245
246
  };
@@ -436,8 +437,23 @@ export function serve(config, opts = {}) {
436
437
  && "type" in decoded && decoded.type === "agent:start"
437
438
  ? LegacyAgentStartSchema.safeParse(decoded)
438
439
  : null;
439
- if (legacy !== null && !legacy.success)
440
+ if (legacy !== null && !legacy.success) {
441
+ // 帧结构不合法(如 server 端已升级到 execution v1 但本 daemon 仍按 legacy 校验)。
442
+ // 曾经静默 return——只表现为"@了 agent 没反应",且完全没有排查线索。记录字段路径和
443
+ // zod 错误码,不记录消息正文/附件等可能敏感的字段值。
444
+ const raw = decoded;
445
+ dslog("run.legacy_frame_rejected", "agent:start 帧未通过 legacy schema 校验,已丢弃", {
446
+ level: "WARN",
447
+ agent_handle: typeof raw.agentHandle === "string" ? raw.agentHandle : undefined,
448
+ channel_id: typeof raw.channelId === "string" ? raw.channelId : undefined,
449
+ reason: typeof raw.reason === "string" ? raw.reason : undefined,
450
+ issue_count: legacy.error.issues.length,
451
+ issue_paths: legacy.error.issues
452
+ .map((issue) => `${issue.path.join(".")}:${issue.code}`)
453
+ .join(","),
454
+ });
440
455
  return;
456
+ }
441
457
  const msg = (legacy?.success ? legacy.data : decoded);
442
458
  // 控制面鉴权拒绝:server 端 resolveToken 未命中有效的 machine 凭证(失效/被吊销/
443
459
  // 库已重置)。不能静默丢弃这帧——否则只表现为神秘的「每 1s 重连」循环。打印可执行
@@ -534,16 +550,28 @@ export function serve(config, opts = {}) {
534
550
  wake_origin: msg.wake?.origin ?? null,
535
551
  content_preview: (msg.wake?.content ?? "").replace(/\s+/g, " ").slice(0, 120),
536
552
  });
537
- if (running.has(key)) {
553
+ if (scheduled && running.has(key)) {
538
554
  log(`↩︎ 跳过(该任务已在运行): ${key}`);
539
555
  dslog("run.dedupe_skip", "跳过唤醒:该任务已在运行", { level: "WARN", ...runKeys });
540
556
  return;
541
557
  }
558
+ const queueWaitStart = Date.now();
559
+ let legacyQueueDone = null;
560
+ let finishLegacyQueue = () => { };
561
+ if (!scheduled) {
562
+ const previous = legacyTaskTails.get(key);
563
+ legacyQueueDone = new Promise((resolve) => { finishLegacyQueue = resolve; });
564
+ legacyTaskTails.set(key, legacyQueueDone);
565
+ if (previous) {
566
+ log(`⏳ 排队(该任务已在运行): ${key}`);
567
+ dslog("run.wake_queued", "唤醒已排队:该任务正在运行", { ...runKeys });
568
+ await previous;
569
+ }
570
+ }
542
571
  running.add(key);
543
572
  // 并行槽:同 agent 超过配置上限的任务在此排队(不丢),有空位再跑。
544
- const slotWaitStart = Date.now();
545
573
  await acquireSlot(msg.agentHandle);
546
- const queueMs = Date.now() - slotWaitStart;
574
+ const queueMs = Date.now() - queueWaitStart;
547
575
  const threadLabel = threadId ?? null;
548
576
  const from = msg.wake?.senderHandle ?? "?";
549
577
  const incoming = msg.wake?.content ?? "";
@@ -769,6 +797,10 @@ export function serve(config, opts = {}) {
769
797
  finally {
770
798
  running.delete(key);
771
799
  releaseSlot(msg.agentHandle);
800
+ finishLegacyQueue();
801
+ if (legacyQueueDone && legacyTaskTails.get(key) === legacyQueueDone) {
802
+ legacyTaskTails.delete(key);
803
+ }
772
804
  void flushSlog(); // 每轮收尾冲一次,保证 run.end 尽快可查
773
805
  }
774
806
  });
package/dist/session.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * per-task claude 会话元数据 —— 让同一任务的重复唤醒用 `--resume` 复用上下文(省 token)。
2
+ * per-task runtime 会话元数据 —— 让同一任务的重复唤醒复用原生上下文(省 token)。
3
3
  *
4
4
  * 存在本任务隔离运行目录下(<runDir>/.crew-session.json):天然按 taskKey 隔离、落盘防 daemon
5
5
  * 重启丢失。读取一律容错 → 不存在/损坏/字段非法都回退 null(= 当作首轮冷启动),绝不阻断运行。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -21,7 +21,7 @@
21
21
  "cross-spawn": "^7.0.6",
22
22
  "ws": "^8",
23
23
  "zod": "^3.23.0",
24
- "@nowcrew/cli": "^0.4.9"
24
+ "@nowcrew/cli": "^0.4.11"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/cross-spawn": "^6.0.6",
@@ -32,7 +32,7 @@
32
32
  "vitest": "^2.1.0"
33
33
  },
34
34
  "scripts": {
35
- "daemon": "tsx src/main.ts",
35
+ "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
36
36
  "build": "tsc -p tsconfig.json",
37
37
  "test": "vitest run",
38
38
  "typecheck": "tsc --noEmit"