@nowcrew/daemon 0.5.29 → 0.5.30

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/README.md CHANGED
@@ -75,6 +75,10 @@ The server selects the machine and sends a validated `execution:start` containin
75
75
  - requested permission and channel/thread identifiers;
76
76
  - reporting flags for final text, activity, and console streams.
77
77
 
78
+ An Agent bound to a computer has a hard machine constraint: if that computer is offline or incompatible,
79
+ dispatch reports unavailable instead of running on another compatible daemon. An unbound Agent may use
80
+ the global compatible-machine pool.
81
+
78
82
  The daemon intersects requested permission with local policy, checks advertised resource limits, prepares
79
83
  the local workspace/environment, and launches only a built-in runtime adapter (`claude`, `codex`, or
80
84
  `kimi`). It does not decide collaboration rules, scheduled output policy, fallback delivery, or thread
@@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import spawn from "cross-spawn";
6
6
  import { z } from "zod";
7
+ import { signalSupervisorTree } from "../execution-supervisor.js";
7
8
  import { startFirstProgressWatchdog } from "./progress-watchdog.js";
8
9
  const RunnerInputSchema = z.object({
9
10
  systemPrompt: z.string().min(1),
@@ -18,6 +19,22 @@ const RunnerInputSchema = z.object({
18
19
  const RPC_TIMEOUT_MS = 30_000;
19
20
  const INITIALIZE_RPC_TIMEOUT_MS = 60_000;
20
21
  const ERROR_MESSAGE_CAP = 2_000;
22
+ // LocalExecutor retains the final 2,000 stderr characters; leave room for stage and error lines.
23
+ const STDERR_TAIL_CAP = 1_200;
24
+ const STDERR_LINE_CAPTURE_CAP = 1_200;
25
+ const STDERR_LINE_OMITTED = "[stderr line omitted: exceeded capture limit]\n";
26
+ const MAX_INITIALIZE_ATTEMPTS = 2;
27
+ const PROCESS_TREE_STOP_TIMEOUT_MS = 1_000;
28
+ class CodexRpcTimeoutError extends Error {
29
+ method;
30
+ timeoutMs;
31
+ constructor(method, timeoutMs) {
32
+ super(`Codex app-server ${method} timed out after ${timeoutMs}ms`);
33
+ this.method = method;
34
+ this.timeoutMs = timeoutMs;
35
+ this.name = "CodexRpcTimeoutError";
36
+ }
37
+ }
21
38
  export function codexRpcTimeoutMs(method) {
22
39
  return method === "initialize" ? INITIALIZE_RPC_TIMEOUT_MS : RPC_TIMEOUT_MS;
23
40
  }
@@ -29,6 +46,87 @@ function safeErrorMessage(error, secrets) {
29
46
  }
30
47
  return message.slice(0, ERROR_MESSAGE_CAP);
31
48
  }
49
+ function sensitiveEnvironmentValues(env) {
50
+ return Object.entries(env)
51
+ .filter(([name, value]) => value !== undefined
52
+ && /(?:api.?key|token|secret|password|credential|authorization)/i.test(name))
53
+ .map(([, value]) => value);
54
+ }
55
+ function redactionSecrets(input, env) {
56
+ const values = [input.systemPrompt, input.wakePrompt, ...sensitiveEnvironmentValues(env)];
57
+ return [...new Set(values.flatMap((value) => [
58
+ value,
59
+ ...value.split(/\r?\n/).map((line) => line.trim()),
60
+ ]).filter(Boolean))];
61
+ }
62
+ export function redactCodexStderr(text, secrets) {
63
+ let redacted = text;
64
+ for (const secret of [...secrets].sort((left, right) => right.length - left.length)) {
65
+ if (secret)
66
+ redacted = redacted.replaceAll(secret, "[redacted]");
67
+ }
68
+ return redacted
69
+ .replace(/(Bearer\s+)[^\s"']+/gi, "$1[redacted]")
70
+ .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
71
+ .replace(/((?:api[_-]?key|access[_-]?token|auth(?:orization)?|password|secret)\s*[=:]\s*)[^\s"']+/gi, "$1[redacted]");
72
+ }
73
+ class StderrCapture {
74
+ secrets;
75
+ tail = "";
76
+ pendingLine = "";
77
+ omittingLine = false;
78
+ constructor(child, secrets) {
79
+ this.secrets = secrets;
80
+ child.stderr?.setEncoding("utf8");
81
+ child.stderr?.on("data", (chunk) => this.append(chunk));
82
+ child.stderr?.once("end", () => this.flush());
83
+ }
84
+ redactedTail() {
85
+ const pending = this.omittingLine
86
+ ? STDERR_LINE_OMITTED
87
+ : redactCodexStderr(this.pendingLine, this.secrets);
88
+ return `${this.tail}${pending}`.slice(-STDERR_TAIL_CAP).trim();
89
+ }
90
+ append(chunk) {
91
+ let remaining = chunk;
92
+ while (remaining.length > 0) {
93
+ const newline = remaining.indexOf("\n");
94
+ const segment = newline < 0 ? remaining : remaining.slice(0, newline);
95
+ if (!this.omittingLine) {
96
+ if (this.pendingLine.length + segment.length <= STDERR_LINE_CAPTURE_CAP) {
97
+ this.pendingLine += segment;
98
+ }
99
+ else {
100
+ this.pendingLine = "";
101
+ this.omittingLine = true;
102
+ }
103
+ }
104
+ if (newline < 0)
105
+ return;
106
+ this.emit(this.omittingLine ? STDERR_LINE_OMITTED : `${this.pendingLine}\n`);
107
+ this.pendingLine = "";
108
+ this.omittingLine = false;
109
+ remaining = remaining.slice(newline + 1);
110
+ }
111
+ }
112
+ flush() {
113
+ if (this.pendingLine.length === 0 && !this.omittingLine)
114
+ return;
115
+ this.emit(this.omittingLine ? STDERR_LINE_OMITTED : this.pendingLine);
116
+ this.pendingLine = "";
117
+ this.omittingLine = false;
118
+ }
119
+ emit(text) {
120
+ const redacted = redactCodexStderr(text, this.secrets);
121
+ process.stderr.write(redacted);
122
+ this.tail = `${this.tail}${redacted}`.slice(-STDERR_TAIL_CAP);
123
+ }
124
+ }
125
+ function logStage(stage, status, attempt, startedAt, detail) {
126
+ const suffix = detail === undefined ? "" : ` ${detail}`;
127
+ process.stderr.write(`[codex-app-server] stage=${stage} status=${status} attempt=${attempt}`
128
+ + ` elapsed_ms=${Date.now() - startedAt}${suffix}\n`);
129
+ }
32
130
  function jsonLine(event) {
33
131
  if (process.stdout.write(`${JSON.stringify(event)}\n`))
34
132
  return Promise.resolve();
@@ -117,7 +215,7 @@ class CodexRpcClient {
117
215
  return new Promise((resolve, reject) => {
118
216
  const timer = setTimeout(() => {
119
217
  this.pending.delete(id);
120
- reject(new Error(`Codex app-server ${method} timed out after ${timeoutMs}ms`));
218
+ reject(new CodexRpcTimeoutError(method, timeoutMs));
121
219
  }, timeoutMs);
122
220
  this.pending.set(id, { resolve, reject, timer });
123
221
  this.write({ jsonrpc: "2.0", id, method, params });
@@ -184,31 +282,70 @@ class CodexRpcClient {
184
282
  this.pending.clear();
185
283
  }
186
284
  }
187
- async function stopChild(child) {
188
- if (child.exitCode !== null || child.signalCode !== null)
285
+ function processTreeAlive(child) {
286
+ if (process.platform === "win32" || child.pid === undefined) {
287
+ return child.exitCode === null && child.signalCode === null;
288
+ }
289
+ try {
290
+ process.kill(-child.pid, 0);
291
+ return true;
292
+ }
293
+ catch (error) {
294
+ return error.code === "EPERM";
295
+ }
296
+ }
297
+ async function signalProcessTree(child, signal) {
298
+ if (child.pid !== undefined) {
299
+ try {
300
+ await signalSupervisorTree(child.pid, signal);
301
+ return;
302
+ }
303
+ catch (error) {
304
+ if (error.code !== "ESRCH")
305
+ throw error;
306
+ }
307
+ }
308
+ if (child.exitCode === null && child.signalCode === null)
309
+ child.kill(signal);
310
+ }
311
+ async function waitForProcessTreeExit(child, timeoutMs) {
312
+ const deadline = Date.now() + timeoutMs;
313
+ while (processTreeAlive(child) && Date.now() < deadline) {
314
+ await new Promise((resolve) => setTimeout(resolve, 25));
315
+ }
316
+ return !processTreeAlive(child);
317
+ }
318
+ async function stopChildTree(child) {
319
+ if (!processTreeAlive(child))
320
+ return;
321
+ await signalProcessTree(child, "SIGTERM");
322
+ if (await waitForProcessTreeExit(child, PROCESS_TREE_STOP_TIMEOUT_MS))
189
323
  return;
190
- const closed = once(child, "close").then(() => undefined);
191
- child.kill("SIGTERM");
192
- let timer;
193
- const graceful = await Promise.race([
194
- closed.then(() => true),
195
- new Promise((resolve) => { timer = setTimeout(() => resolve(false), 1_000); }),
196
- ]);
197
- if (timer !== undefined)
198
- clearTimeout(timer);
199
- if (!graceful && child.exitCode === null && child.signalCode === null) {
200
- child.kill("SIGKILL");
201
- await closed;
324
+ await signalProcessTree(child, "SIGKILL");
325
+ if (!await waitForProcessTreeExit(child, PROCESS_TREE_STOP_TIMEOUT_MS)) {
326
+ throw new Error(`Codex app-server process tree ${child.pid ?? "unknown"} did not exit`);
202
327
  }
203
328
  }
204
- export async function runCodexAppServer(bin) {
205
- const input = await readRunnerInput();
329
+ async function runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs) {
330
+ const attemptStartedAt = Date.now();
206
331
  const child = spawn(bin, ["app-server", "--listen", "stdio://"], {
207
332
  cwd: process.cwd(),
208
333
  env: process.env,
209
334
  stdio: ["pipe", "pipe", "pipe"],
335
+ detached: process.platform !== "win32",
336
+ });
337
+ const secrets = redactionSecrets(input, process.env);
338
+ const stderr = new StderrCapture(child, secrets);
339
+ logStage("spawn", "start", attempt, attemptStartedAt, `pid=${child.pid ?? "unknown"}`);
340
+ let didSpawn = false;
341
+ child.once("spawn", () => {
342
+ didSpawn = true;
343
+ logStage("spawn", "ok", attempt, attemptStartedAt, `pid=${child.pid ?? "unknown"}`);
344
+ });
345
+ child.once("error", () => {
346
+ if (!didSpawn)
347
+ logStage("spawn", "error", attempt, attemptStartedAt);
210
348
  });
211
- child.stderr?.pipe(process.stderr, { end: false });
212
349
  let threadId = null;
213
350
  let announcedThreadId = null;
214
351
  let turnId = null;
@@ -246,6 +383,11 @@ export async function runCodexAppServer(bin) {
246
383
  process.stderr.write(`Codex turn error: ${detail.slice(0, ERROR_MESSAGE_CAP)}\n`);
247
384
  }
248
385
  }, completionReject);
386
+ let treeStopPromise = null;
387
+ const ensureChildTreeStopped = () => {
388
+ treeStopPromise ??= stopChildTree(child);
389
+ return treeStopPromise;
390
+ };
249
391
  let cancelling = false;
250
392
  const cancel = async () => {
251
393
  if (cancelling)
@@ -254,17 +396,22 @@ export async function runCodexAppServer(bin) {
254
396
  if (threadId !== null && turnId !== null) {
255
397
  await rpc.request("turn/interrupt", { threadId, turnId }, 5_000).catch(() => undefined);
256
398
  }
257
- await stopChild(child);
399
+ await ensureChildTreeStopped();
258
400
  };
259
401
  const onSignal = () => { void cancel().finally(() => process.exit(130)); };
260
402
  process.once("SIGTERM", onSignal);
261
403
  process.once("SIGINT", onSignal);
404
+ let activeStage = "initialize";
405
+ let stageStartedAt = Date.now();
262
406
  try {
263
407
  await rpc.request("initialize", {
264
408
  clientInfo: { name: "nowcrew-daemon", version: "1" },
265
409
  capabilities: { experimentalApi: true, requestAttestation: false },
266
- });
410
+ }, initializeTimeoutMs);
411
+ logStage(activeStage, "ok", attempt, stageStartedAt);
267
412
  rpc.notify("initialized");
413
+ activeStage = input.resume ? "thread_resume" : "thread_start";
414
+ stageStartedAt = Date.now();
268
415
  const threadParams = {
269
416
  cwd: process.cwd(),
270
417
  approvalPolicy: "never",
@@ -275,6 +422,7 @@ export async function runCodexAppServer(bin) {
275
422
  const thread = input.resume && input.sessionId !== undefined
276
423
  ? await rpc.request("thread/resume", { threadId: input.sessionId, ...threadParams })
277
424
  : await rpc.request("thread/start", threadParams);
425
+ logStage(activeStage, "ok", attempt, stageStartedAt);
278
426
  threadId = thread.thread.id;
279
427
  if (announcedThreadId !== threadId) {
280
428
  announcedThreadId = threadId;
@@ -284,6 +432,8 @@ export async function runCodexAppServer(bin) {
284
432
  completionReject(new Error("Codex produced no semantic progress within the startup window"));
285
433
  void cancel();
286
434
  });
435
+ activeStage = "turn_start";
436
+ stageStartedAt = Date.now();
287
437
  const started = await rpc.request("turn/start", {
288
438
  threadId,
289
439
  input: [
@@ -294,8 +444,12 @@ export async function runCodexAppServer(bin) {
294
444
  ? {}
295
445
  : { effort: input.reasoning }),
296
446
  });
447
+ logStage(activeStage, "ok", attempt, stageStartedAt);
297
448
  turnId = started.turn.id;
449
+ activeStage = "turn_complete";
450
+ stageStartedAt = Date.now();
298
451
  const completed = await completion;
452
+ logStage(activeStage, "ok", attempt, stageStartedAt);
299
453
  firstProgress.stop();
300
454
  const usage = lastUsage?.last;
301
455
  await jsonLine({
@@ -309,33 +463,76 @@ export async function runCodexAppServer(bin) {
309
463
  }),
310
464
  });
311
465
  if (completed.turn?.status === "completed")
312
- return 0;
466
+ return { code: 0, initializeTimedOut: false };
313
467
  const detail = completed.turn?.error?.message ?? `turn status ${completed.turn?.status ?? "unknown"}`;
314
468
  process.stderr.write(`Codex turn failed: ${detail.slice(0, ERROR_MESSAGE_CAP)}\n`);
315
- return completed.turn?.status === "interrupted" ? 130 : 1;
469
+ return {
470
+ code: completed.turn?.status === "interrupted" ? 130 : 1,
471
+ initializeTimedOut: false,
472
+ };
316
473
  }
317
474
  catch (error) {
318
- process.stderr.write(`Codex app-server execution failed: ${safeErrorMessage(error, [input.systemPrompt, input.wakePrompt])}\n`);
319
- return 1;
475
+ const initializeTimedOut = error instanceof CodexRpcTimeoutError && error.method === "initialize";
476
+ if (initializeTimedOut) {
477
+ const tail = stderr.redactedTail();
478
+ logStage(activeStage, "timeout", attempt, stageStartedAt, `stderr_tail=${JSON.stringify(tail || "[empty]")}`);
479
+ }
480
+ else {
481
+ logStage(activeStage, "error", attempt, stageStartedAt);
482
+ }
483
+ process.stderr.write(`Codex app-server execution failed: ${safeErrorMessage(error, secrets)}\n`);
484
+ return { code: 1, initializeTimedOut };
320
485
  }
321
486
  finally {
322
487
  firstProgress.stop();
323
488
  process.off("SIGTERM", onSignal);
324
489
  process.off("SIGINT", onSignal);
325
- await stopChild(child);
490
+ const cleanupStartedAt = Date.now();
491
+ try {
492
+ await ensureChildTreeStopped();
493
+ logStage("cleanup", "ok", attempt, cleanupStartedAt);
494
+ }
495
+ catch (error) {
496
+ logStage("cleanup", "error", attempt, cleanupStartedAt);
497
+ throw error;
498
+ }
499
+ }
500
+ }
501
+ export async function runCodexAppServer(bin, options = {}) {
502
+ const input = await readRunnerInput();
503
+ const initializeTimeoutMs = Math.min(options.initializeTimeoutMs ?? INITIALIZE_RPC_TIMEOUT_MS, INITIALIZE_RPC_TIMEOUT_MS);
504
+ for (let attempt = 1; attempt <= MAX_INITIALIZE_ATTEMPTS; attempt += 1) {
505
+ const result = await runCodexAppServerAttempt(bin, input, attempt, initializeTimeoutMs);
506
+ if (!result.initializeTimedOut || attempt === MAX_INITIALIZE_ATTEMPTS)
507
+ return result.code;
508
+ process.stderr.write(`[codex-app-server] stage=retry status=start attempt=${attempt + 1} elapsed_ms=0 reason=initialize_timeout\n`);
326
509
  }
510
+ return 1;
327
511
  }
328
- function binFromArgv(argv) {
512
+ function configFromArgv(argv) {
329
513
  const { values } = parseArgs({
330
514
  args: [...argv],
331
- options: { bin: { type: "string" } },
515
+ options: {
516
+ bin: { type: "string" },
517
+ "initialize-timeout-ms": { type: "string" },
518
+ },
332
519
  });
333
520
  if (!values.bin)
334
521
  throw new Error("--bin is required");
335
- return values.bin;
522
+ const rawTimeout = values["initialize-timeout-ms"];
523
+ if (rawTimeout === undefined)
524
+ return { bin: values.bin };
525
+ const initializeTimeoutMs = Number(rawTimeout);
526
+ if (!Number.isInteger(initializeTimeoutMs) || initializeTimeoutMs <= 0) {
527
+ throw new Error("--initialize-timeout-ms must be a positive integer");
528
+ }
529
+ return { bin: values.bin, initializeTimeoutMs };
336
530
  }
337
531
  if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
338
- runCodexAppServer(binFromArgv(process.argv.slice(2)))
532
+ const config = configFromArgv(process.argv.slice(2));
533
+ runCodexAppServer(config.bin, config.initializeTimeoutMs === undefined
534
+ ? {}
535
+ : { initializeTimeoutMs: config.initializeTimeoutMs })
339
536
  .then((code) => { process.exitCode = code; })
340
537
  .catch((error) => {
341
538
  process.stderr.write(`Codex app-server runner failed: ${safeErrorMessage(error, [])}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.5.29",
3
+ "version": "0.5.30",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",