@nowcrew/daemon 0.5.18 → 0.5.20

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.
Files changed (41) hide show
  1. package/README.md +23 -0
  2. package/dist/attachments.js +196 -0
  3. package/dist/computer-cli.js +72 -12
  4. package/dist/computer-profile-lock.js +395 -0
  5. package/dist/computer-profile.js +189 -20
  6. package/dist/config.js +2 -1
  7. package/dist/console.js +175 -9
  8. package/dist/execution-event-limit.js +1 -1
  9. package/dist/execution-journal-lock.js +199 -40
  10. package/dist/execution-journal.js +42 -4
  11. package/dist/execution-protocol.js +21 -1
  12. package/dist/execution-recovery.js +71 -0
  13. package/dist/execution-runner.js +68 -77
  14. package/dist/execution-supervisor.js +79 -31
  15. package/dist/external-output.js +114 -0
  16. package/dist/i18n.js +5 -5
  17. package/dist/list-models.js +41 -5
  18. package/dist/local-executor.js +103 -14
  19. package/dist/machine-info.js +6 -1
  20. package/dist/main.js +23 -8
  21. package/dist/origin-decision.js +3 -1
  22. package/dist/prompt.js +4 -1
  23. package/dist/runner.js +14 -9
  24. package/dist/runtime-cancellation.js +74 -0
  25. package/dist/runtime-capabilities.js +38 -0
  26. package/dist/runtime-path.js +60 -0
  27. package/dist/runtimes/claude.js +9 -4
  28. package/dist/runtimes/codex-app-server-runner.js +340 -0
  29. package/dist/runtimes/codex.js +10 -4
  30. package/dist/runtimes/kimi-acp-runner.js +117 -17
  31. package/dist/runtimes/kimi.js +2 -0
  32. package/dist/runtimes/progress-watchdog.js +26 -0
  33. package/dist/serve-lifecycle.js +82 -0
  34. package/dist/serve.js +212 -212
  35. package/dist/session.js +1 -1
  36. package/dist/shared-execution-slots.js +68 -0
  37. package/dist/shutdown-deadline.js +32 -0
  38. package/dist/slog.js +34 -20
  39. package/dist/supervised-runtime.js +104 -0
  40. package/dist/websocket-shutdown.js +53 -0
  41. package/package.json +3 -3
@@ -0,0 +1,114 @@
1
+ export const EXTERNAL_ANSWER_OPEN = "<nowwork_external_answer>";
2
+ export const EXTERNAL_ANSWER_CLOSE = "</nowwork_external_answer>";
3
+ function retainedMarkerPrefix(value, marker) {
4
+ const maximum = Math.min(value.length, marker.length - 1);
5
+ for (let length = maximum; length > 0; length -= 1) {
6
+ if (marker.startsWith(value.slice(-length)))
7
+ return length;
8
+ }
9
+ return 0;
10
+ }
11
+ export class ExternalAnswerDecoder {
12
+ state = "outside";
13
+ pending = "";
14
+ push(text) {
15
+ if (!text)
16
+ return [];
17
+ const output = [];
18
+ this.consume(this.pending + text, output);
19
+ return output;
20
+ }
21
+ finish() {
22
+ this.pending = "";
23
+ this.state = "outside";
24
+ return [];
25
+ }
26
+ consume(value, output) {
27
+ this.pending = "";
28
+ const marker = this.state === "outside" ? EXTERNAL_ANSWER_OPEN : EXTERNAL_ANSWER_CLOSE;
29
+ const markerAt = value.indexOf(marker);
30
+ if (markerAt >= 0) {
31
+ if (this.state === "inside" && markerAt > 0)
32
+ output.push(value.slice(0, markerAt));
33
+ this.state = this.state === "outside" ? "inside" : "outside";
34
+ const remaining = value.slice(markerAt + marker.length);
35
+ if (remaining)
36
+ this.consume(remaining, output);
37
+ return;
38
+ }
39
+ const retained = retainedMarkerPrefix(value, marker);
40
+ const safe = retained > 0 ? value.slice(0, -retained) : value;
41
+ this.pending = retained > 0 ? value.slice(-retained) : "";
42
+ if (this.state === "inside" && safe)
43
+ output.push(safe);
44
+ }
45
+ }
46
+ export function decodeExternalOutputEvent(runtime, event, decoder) {
47
+ if (runtime === "claude") {
48
+ const candidate = (event ?? {});
49
+ const delta = candidate.event?.delta;
50
+ if (candidate.type !== "stream_event"
51
+ || candidate.event?.type !== "content_block_delta"
52
+ || delta?.type !== "text_delta"
53
+ || typeof delta.text !== "string")
54
+ return [];
55
+ return decoder.push(delta.text);
56
+ }
57
+ const candidate = (event ?? {});
58
+ if (runtime === "codex") {
59
+ return candidate.type === "item.completed"
60
+ && candidate.item?.type === "agent_message"
61
+ && typeof candidate.item.text === "string"
62
+ ? decoder.push(candidate.item.text)
63
+ : [];
64
+ }
65
+ return candidate.type === undefined
66
+ && candidate.role === "assistant"
67
+ && typeof candidate.content === "string"
68
+ ? decoder.push(candidate.content)
69
+ : [];
70
+ }
71
+ export function stripExternalAnswerMarkers(value) {
72
+ const sections = [];
73
+ let cursor = 0;
74
+ while (cursor < value.length) {
75
+ const openAt = value.indexOf(EXTERNAL_ANSWER_OPEN, cursor);
76
+ if (openAt < 0)
77
+ break;
78
+ const contentAt = openAt + EXTERNAL_ANSWER_OPEN.length;
79
+ const closeAt = value.indexOf(EXTERNAL_ANSWER_CLOSE, contentAt);
80
+ if (closeAt < 0)
81
+ return value.slice(contentAt).trim();
82
+ sections.push(value.slice(contentAt, closeAt));
83
+ cursor = closeAt + EXTERNAL_ANSWER_CLOSE.length;
84
+ }
85
+ return (sections.length > 0 ? sections.join("") : value).trim();
86
+ }
87
+ /**
88
+ * 提取 marker 内容:有 marker 返回拼接内容(trim,空→null),无 marker 返回 null。
89
+ * 与 stripExternalAnswerMarkers 的区别:strip 在无 marker 时回退整段原文(finalText 展示用),
90
+ * 本函数用于判定"agent 是否给出了频道直接回复"——必须能区分有无 marker。
91
+ */
92
+ export function extractExternalAnswer(value) {
93
+ const parts = [];
94
+ let found = false;
95
+ let rest = value;
96
+ for (;;) {
97
+ const open = rest.indexOf(EXTERNAL_ANSWER_OPEN);
98
+ if (open < 0)
99
+ break;
100
+ found = true;
101
+ const afterOpen = rest.slice(open + EXTERNAL_ANSWER_OPEN.length);
102
+ const close = afterOpen.indexOf(EXTERNAL_ANSWER_CLOSE);
103
+ if (close < 0) {
104
+ parts.push(afterOpen);
105
+ break;
106
+ }
107
+ parts.push(afterOpen.slice(0, close));
108
+ rest = afterOpen.slice(close + EXTERNAL_ANSWER_CLOSE.length);
109
+ }
110
+ if (!found)
111
+ return null;
112
+ const answer = parts.join("").trim();
113
+ return answer.length > 0 ? answer : null;
114
+ }
package/dist/i18n.js CHANGED
@@ -17,6 +17,8 @@ export function detectDaemonLang(env = process.env) {
17
17
  }
18
18
  const zh = {
19
19
  "Claude session started": "Claude 会话启动",
20
+ "Codex session started": "Codex 会话启动",
21
+ "Files changed": "文件变更",
20
22
  "Run failed": "运行出错",
21
23
  "Run finished": "本轮结束",
22
24
  "Missing CREW_MACHINE_TOKEN (sk_machine_*, printed by seed)": "缺少 CREW_MACHINE_TOKEN(sk_machine_*,由 seed 打印)",
@@ -40,6 +42,7 @@ const zh = {
40
42
  "Saved profile '{{name}}' with private credentials.": "已保存配置 '{{name}}',凭证仅私有可读。",
41
43
  "Service '{{id}}' is not installed": "服务 '{{id}}' 尚未安装",
42
44
  "Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.": "daemon 已升级,并已请求重启 '{{name}}';请用 status 确认。",
45
+ "Upgraded daemon but skipped restart for '{{name}}': {{reason}}": "daemon 已升级,但已跳过 '{{name}}' 的重启:{{reason}}",
43
46
  "Upgraded daemon. Installed services were not restarted; pass --profile to restart one.": "daemon 已升级;已安装服务尚未重启,可传入 --profile 重启指定服务。",
44
47
  "Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry": "服务生命周期必须使用已构建的 daemon 入口(.js),不能使用 TypeScript 开发入口",
45
48
  "Installed '{{id}}'. Use status to confirm runtime state.": "已安装 '{{id}}';请用 status 确认运行状态。",
@@ -47,6 +50,7 @@ const zh = {
47
50
  "{{action}} request accepted for '{{id}}'. Verify with status.": "已接受对 '{{id}}' 的 {{action}} 请求;请用 status 确认。",
48
51
  "--token-stdin requires a token on standard input": "--token-stdin 需要从标准输入读取令牌",
49
52
  "Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest": "服务生命周期需要全局安装 @nowcrew/daemon;请运行 npm install --global @nowcrew/daemon@latest",
53
+ "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}": "配置 '{{profile}}' 与配置 '{{conflict}}' 解析到了同一个 agents root '{{agentsRoot}}'。请保存为唯一目录,例如:{{command}}",
50
54
  };
51
55
  export function translateDaemon(lang, message) {
52
56
  if (lang === "zh")
@@ -54,9 +58,5 @@ export function translateDaemon(lang, message) {
54
58
  return message;
55
59
  }
56
60
  export function formatDaemonText(lang, message, values = {}) {
57
- let rendered = translateDaemon(lang, message);
58
- for (const [key, value] of Object.entries(values)) {
59
- rendered = rendered.replaceAll(`{{${key}}}`, String(value));
60
- }
61
- return rendered;
61
+ return translateDaemon(lang, message).replace(/\{\{([^{}]+)\}\}/g, (token, key) => Object.hasOwn(values, key) ? String(values[key]) : token);
62
62
  }
@@ -2,8 +2,15 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { isWin } from "./platform.js";
4
4
  const execFileRaw = promisify(execFile);
5
+ const MODEL_PROBE_TIMEOUT_MS = 8_000;
6
+ const MODEL_PROBE_MAX_BUFFER_BYTES = 1024 * 1024;
5
7
  // win32 上 npm CLI 是 .cmd shim,execFile 需 shell 才能执行;参数全是固定字面量,无注入面。
6
- const execFileP = (bin, args) => execFileRaw(bin, args, { shell: isWin() });
8
+ const execFileP = (bin, args) => execFileRaw(bin, args, {
9
+ shell: isWin(),
10
+ timeout: MODEL_PROBE_TIMEOUT_MS,
11
+ killSignal: "SIGKILL",
12
+ maxBuffer: MODEL_PROBE_MAX_BUFFER_BYTES,
13
+ });
7
14
  export async function listRuntimeModels(runtime) {
8
15
  switch (runtime) {
9
16
  case "codex":
@@ -21,15 +28,44 @@ export async function listRuntimeModels(runtime) {
21
28
  function parseCodexModels(stdout) {
22
29
  try {
23
30
  const parsed = JSON.parse(stdout);
24
- const rows = Array.isArray(parsed.models) ? parsed.models : [];
25
- return rows
26
- .filter((m) => typeof m.id === "string" && (m.visibility == null || m.visibility === "list"))
27
- .map((m, index) => ({ id: m.id, label: m.name || m.id, ...((m.default || index === 0) ? { default: true } : {}) }));
31
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.models)) {
32
+ return [];
33
+ }
34
+ return normalizeCodexModels(parsed.models);
28
35
  }
29
36
  catch {
30
37
  return [];
31
38
  }
32
39
  }
40
+ function normalizeCodexModels(rows) {
41
+ const normalized = rows.flatMap((value) => {
42
+ if (typeof value !== "object" || value === null)
43
+ return [];
44
+ const row = value;
45
+ if (row.visibility != null && row.visibility !== "list")
46
+ return [];
47
+ const id = nonEmptyString(row.slug) ?? nonEmptyString(row.id);
48
+ if (id === null)
49
+ return [];
50
+ return [{
51
+ id,
52
+ label: nonEmptyString(row.display_name) ?? nonEmptyString(row.name) ?? id,
53
+ explicitDefault: row.default === true,
54
+ }];
55
+ });
56
+ const hasExplicitDefault = normalized.some((model) => model.explicitDefault);
57
+ return normalized.map((model, index) => ({
58
+ id: model.id,
59
+ label: model.label,
60
+ ...((model.explicitDefault || (!hasExplicitDefault && index === 0)) ? { default: true } : {}),
61
+ }));
62
+ }
63
+ function nonEmptyString(value) {
64
+ if (typeof value !== "string")
65
+ return null;
66
+ const trimmed = value.trim();
67
+ return trimmed.length > 0 ? trimmed : null;
68
+ }
33
69
  function parseCursorModels(stdout) {
34
70
  return stdout
35
71
  .split(/\r?\n/)
@@ -6,10 +6,15 @@ import { spawnClaude } from "./runtimes/claude.js";
6
6
  import { spawnCodex } from "./runtimes/codex.js";
7
7
  import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
8
8
  import { applyProviderEnv, providerFingerprint } from "./provider-env.js";
9
+ import { augmentedPath } from "./runtime-path.js";
9
10
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
10
11
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
11
12
  import { toConsoleLines } from "./console.js";
12
13
  import { capMemoryForInject, capWorkLogForInject } from "./prompt.js";
14
+ import { decodeExternalOutputEvent, extractExternalAnswer, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
15
+ import { cleanupMaterializedAttachments as cleanupAttachments, executionAttachmentDirectory, materializeAttachments, } from "./attachments.js";
16
+ import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilities.js";
17
+ import { awaitWithCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
13
18
  function truncateUtf8(value, maxBytes) {
14
19
  if (maxBytes <= 0)
15
20
  return "";
@@ -118,6 +123,7 @@ async function launchLegacyRuntime(request) {
118
123
  return wrapChild(spawnCodex({
119
124
  ...common,
120
125
  wakePrompt: `${request.systemPrompt}\n\n${request.wakePrompt}`,
126
+ ...(request.imagePaths === undefined ? {} : { imagePaths: request.imagePaths }),
121
127
  }));
122
128
  }
123
129
  return wrapChild(spawnKimi({
@@ -133,24 +139,29 @@ function resolvePrompt(prompt, context) {
133
139
  return typeof prompt === "string" ? prompt : prompt(context);
134
140
  }
135
141
  const nativeSessionLeaseTails = new Map();
136
- async function withKeyedLease(key, operation) {
142
+ async function withKeyedLease(key, operation, cancellation) {
137
143
  const predecessor = nativeSessionLeaseTails.get(key) ?? Promise.resolve();
138
144
  let release;
139
145
  const current = new Promise((resolve) => { release = resolve; });
140
146
  const tail = predecessor.then(() => current);
141
147
  nativeSessionLeaseTails.set(key, tail);
142
- await predecessor;
143
148
  try {
149
+ await awaitWithCancellation(predecessor, cancellation);
144
150
  return await operation();
145
151
  }
146
152
  finally {
147
153
  release();
148
- if (nativeSessionLeaseTails.get(key) === tail)
149
- nativeSessionLeaseTails.delete(key);
154
+ void tail.then(() => {
155
+ if (nativeSessionLeaseTails.get(key) === tail)
156
+ nativeSessionLeaseTails.delete(key);
157
+ });
150
158
  }
151
159
  }
152
160
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
153
- if (input.runtime.name !== "claude" || !input.session.enabled) {
161
+ const stableProtocolRuntime = dependencies.launchRuntime !== undefined;
162
+ const supportsNativeResume = input.runtime.name === "claude"
163
+ || (stableProtocolRuntime && runtimeCapability(input.runtime.name).nativeResume);
164
+ if (!supportsNativeResume || !input.session.enabled) {
154
165
  return executeLocalUnlocked(input, callbacks, dependencies);
155
166
  }
156
167
  const leaseKey = JSON.stringify([
@@ -159,14 +170,14 @@ export async function executeLocal(input, callbacks = {}, dependencies = {}) {
159
170
  input.keyMode ?? "legacy",
160
171
  input.resumeKey ?? input.taskKey ?? "",
161
172
  ]);
162
- return withKeyedLease(leaseKey, () => executeLocalUnlocked(input, callbacks, dependencies));
173
+ return withKeyedLease(leaseKey, () => executeLocalUnlocked(input, callbacks, dependencies), dependencies.cancellation);
163
174
  }
164
175
  async function executeLocalUnlocked(input, callbacks, dependencies) {
165
176
  const providerConfig = input.launch.providerConfig ?? {};
166
177
  const { runtime } = input;
167
178
  const currentModel = runtime.model ?? null;
168
179
  const providerFp = providerFingerprint(runtime.name, providerConfig);
169
- const workspace = await prepareWorkspace({
180
+ const workspace = await awaitWithCancellation(prepareWorkspace({
170
181
  agentsRoot: input.launch.agentsRoot,
171
182
  handle: input.handle,
172
183
  cliPath: input.launch.cliPath,
@@ -175,9 +186,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
175
186
  ...(input.taskKey === undefined ? {} : { taskKey: input.taskKey }),
176
187
  ...(input.resumeKey === undefined ? {} : { resumeKey: input.resumeKey }),
177
188
  ...(input.launch.description ? { description: input.launch.description } : {}),
178
- });
189
+ }), dependencies.cancellation);
190
+ let materialized = null;
191
+ let knownAttachmentDirectory = null;
179
192
  try {
180
- const supportsNativeResume = runtime.name === "claude";
193
+ const supportsNativeResume = runtime.name === "claude"
194
+ || (dependencies.launchRuntime !== undefined && runtimeCapability(runtime.name).nativeResume);
181
195
  const prior = input.session.enabled && supportsNativeResume
182
196
  ? await readSession(workspace.sessionDir)
183
197
  : null;
@@ -197,13 +211,44 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
197
211
  nearBudget,
198
212
  };
199
213
  const systemPrompt = resolvePrompt(input.systemPrompt, promptContext);
200
- const wakePrompt = resolvePrompt(input.wakePrompt, promptContext);
201
- await writeFile(workspace.systemPromptPath, systemPrompt, "utf8");
214
+ if (input.attachments && input.attachments.length > 0) {
215
+ if (dependencies.cancellation?.isRequested())
216
+ throw new RuntimeCancelledError();
217
+ knownAttachmentDirectory = executionAttachmentDirectory(workspace.runDir, input.executionId);
218
+ const controller = new AbortController();
219
+ const materialization = Promise.resolve().then(() => (dependencies.materializeAttachments ?? materializeAttachments)({
220
+ serverUrl: input.launch.serverUrl,
221
+ token: input.launch.token,
222
+ runDir: workspace.runDir,
223
+ executionId: input.executionId,
224
+ attachments: input.attachments,
225
+ signal: controller.signal,
226
+ }));
227
+ dependencies.cancellation?.register(async () => {
228
+ controller.abort(new RuntimeCancelledError());
229
+ await materialization.then(() => undefined, () => undefined);
230
+ });
231
+ try {
232
+ materialized = await materialization;
233
+ }
234
+ catch (error) {
235
+ if (dependencies.cancellation?.isRequested())
236
+ throw new RuntimeCancelledError();
237
+ throw error;
238
+ }
239
+ if (dependencies.cancellation?.isRequested()) {
240
+ await dependencies.cancellation.waitForStop();
241
+ throw new RuntimeCancelledError();
242
+ }
243
+ }
244
+ const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
245
+ const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
246
+ await awaitWithCancellation(writeFile(workspace.systemPromptPath, systemPrompt, "utf8"), dependencies.cancellation);
202
247
  const baseEnv = {
203
248
  ...process.env,
204
249
  ...sanitizeEnvVars(providerConfig.envVars),
205
250
  ...input.launch.systemEnv,
206
- PATH: `${workspace.crewDir}${delimiter}${process.env.PATH ?? ""}`,
251
+ PATH: `${workspace.crewDir}${delimiter}${augmentedPath()}`,
207
252
  CREW_SERVER_URL: input.launch.serverUrl,
208
253
  CREW_TOKEN: input.launch.token,
209
254
  CREW_CHANNEL: input.channelId,
@@ -222,6 +267,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
222
267
  };
223
268
  const childEnv = applyProviderEnv(baseEnv, runtime.name, providerConfig, workspace.homeDir);
224
269
  const launchRuntime = dependencies.launchRuntime ?? launchLegacyRuntime;
270
+ if (dependencies.cancellation?.isRequested())
271
+ throw new RuntimeCancelledError();
225
272
  const child = await launchRuntime({
226
273
  runtime: runtime.name,
227
274
  bin: runtime.name,
@@ -235,13 +282,24 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
235
282
  ...(runtime.reasoning === undefined ? {} : { reasoning: runtime.reasoning }),
236
283
  ...(launchSessionId === null ? {} : { sessionId: launchSessionId }),
237
284
  resume: resuming,
285
+ ...(attachmentPlan.nativeImagePaths.length > 0
286
+ ? { imagePaths: attachmentPlan.nativeImagePaths }
287
+ : {}),
238
288
  });
289
+ if (child.cancel !== undefined) {
290
+ dependencies.cancellation?.register(child.cancel);
291
+ }
292
+ if (dependencies.cancellation?.isRequested()) {
293
+ await dependencies.cancellation.waitForStop();
294
+ throw new RuntimeCancelledError();
295
+ }
239
296
  const activities = [];
240
297
  let sessionId = launchSessionId;
241
298
  let usage;
242
299
  let observedModel = currentModel;
243
300
  let finalText = null;
244
301
  let sentViaCrew = false;
302
+ const externalOutput = new ExternalAnswerDecoder();
245
303
  const readline = createInterface({ input: child.stdout });
246
304
  readline.on("line", (line) => {
247
305
  const event = parseLine(line);
@@ -266,6 +324,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
266
324
  && "type" in event && event.type === "kimi.acp.text_delta";
267
325
  finalText = incremental ? `${finalText ?? ""}${extracted}` : extracted;
268
326
  }
327
+ for (const text of decodeExternalOutputEvent(runtime.name, event, externalOutput)) {
328
+ callbacks.onExternalOutput?.(text);
329
+ }
269
330
  for (const chunk of toConsoleLines(event))
270
331
  callbacks.onConsole?.(chunk);
271
332
  });
@@ -274,7 +335,17 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
274
335
  process.stderr.write(data);
275
336
  stderrTail = (stderrTail + String(data)).slice(-STDERR_TAIL_CAP);
276
337
  });
277
- const { exitCode, spawnError, terminationSignal } = await child.exit;
338
+ let runtimeExit;
339
+ try {
340
+ runtimeExit = await awaitWithCancellation(child.exit, dependencies.cancellation);
341
+ }
342
+ catch (error) {
343
+ if (error instanceof RuntimeCancelledError) {
344
+ await dependencies.cancellation?.waitForStop();
345
+ }
346
+ throw error;
347
+ }
348
+ const { exitCode, spawnError, terminationSignal } = runtimeExit;
278
349
  const errorTail = [
279
350
  stderrTail.trim(),
280
351
  spawnError,
@@ -313,11 +384,29 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
313
384
  resumed: resuming,
314
385
  sessionId,
315
386
  errorMessage: errorTail || null,
316
- finalText: input.captureFinal ? finalText : null,
387
+ finalText: input.captureFinal && finalText !== null
388
+ ? stripExternalAnswerMarkers(finalText)
389
+ : null,
390
+ externalAnswer: input.captureFinal && finalText !== null
391
+ ? extractExternalAnswer(finalText)
392
+ : null,
317
393
  sentViaCrew,
318
394
  };
319
395
  }
320
396
  finally {
397
+ const attachmentDirectories = new Set([
398
+ ...(knownAttachmentDirectory === null ? [] : [knownAttachmentDirectory]),
399
+ ...(materialized === null ? [] : [materialized.directory]),
400
+ ]);
401
+ for (const directory of attachmentDirectories) {
402
+ try {
403
+ await (dependencies.cleanupMaterializedAttachments ?? cleanupAttachments)(directory);
404
+ }
405
+ catch (error) {
406
+ const detail = error instanceof Error ? error.message : String(error);
407
+ process.stderr.write(`[execution] failed to remove attachments ${directory}: ${detail}\n`);
408
+ }
409
+ }
321
410
  try {
322
411
  await rm(workspace.systemPromptPath, { force: true });
323
412
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { hostname, arch, platform as osPlatform } from "node:os";
6
6
  import { lookupCmd } from "./platform.js";
7
+ import { augmentedPath } from "./runtime-path.js";
7
8
  import { execFile } from "node:child_process";
8
9
  import { promisify } from "node:util";
9
10
  import { readFileSync } from "node:fs";
@@ -21,6 +22,9 @@ export const DAEMON_CAPABILITIES = [
21
22
  "reply_origin_v1",
22
23
  "origin_decision_v1",
23
24
  "execution_telemetry_ack_v1",
25
+ "execution_external_output_v1",
26
+ "execution_attachments_v1",
27
+ "execution_answer_stream_v1",
24
28
  ];
25
29
  export const EXECUTION_PROTOCOL = Object.freeze({ min: 1, max: 1 });
26
30
  /** 候选 runtime CLI:展示名 → 可执行文件名。 */
@@ -36,7 +40,8 @@ const RUNTIME_BINS = [
36
40
  ];
37
41
  async function isInstalled(bin) {
38
42
  try {
39
- await execFileP(lookupCmd(), [bin]);
43
+ // 用增强 PATH 探测:daemon 继承的 PATH 可能缺 ~/.local/bin 等用户级目录(Claude 原生安装器落点)
44
+ await execFileP(lookupCmd(), [bin], { env: { ...process.env, PATH: augmentedPath() } });
40
45
  return true;
41
46
  }
42
47
  catch {
package/dist/main.js CHANGED
@@ -6,15 +6,17 @@
6
6
  * 后续 (M3c):常驻 + 连 server 控制面 WS,由 agent:start 自动唤醒。
7
7
  */
8
8
  import { parseArgs } from "node:util";
9
+ import { homedir } from "node:os";
9
10
  import { loadConfig, ConfigError } from "./config.js";
10
- import { detectDaemonLang, translateDaemon } from "./i18n.js";
11
+ import { detectDaemonLang, formatDaemonText, translateDaemon } from "./i18n.js";
11
12
  import { cliVersion, daemonVersion } from "./machine-info.js";
12
13
  import { runAgent } from "./runner.js";
13
14
  import { serve } from "./serve.js";
14
15
  import { initSlog, flushSlog } from "./slog.js";
15
16
  import { formatDaemonLogLine } from "./log-format.js";
16
- import { loadProfile, applyProfileToEnv } from "./computer-profile.js";
17
+ import { applyProfileToEnv, assertProfileAgentsRootUnique, daemonHome, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, } from "./computer-profile.js";
17
18
  import { runComputerCommand } from "./computer-cli.js";
19
+ import { runServeLifecycle } from "./serve-lifecycle.js";
18
20
  async function main() {
19
21
  const computerResult = await runComputerCommand(process.argv.slice(2));
20
22
  if (computerResult !== null) {
@@ -51,8 +53,13 @@ async function main() {
51
53
  // 不把 machine token 放进 argv / plist / systemd unit / scheduled task。
52
54
  if (values["daemon-home"])
53
55
  process.env.CREW_DAEMON_HOME = values["daemon-home"];
54
- if (values.profile)
55
- applyProfileToEnv(await loadProfile(values.profile), process.env);
56
+ if (values.profile) {
57
+ const home = daemonHome();
58
+ const profile = await loadProfile(values.profile, home);
59
+ if (cmd === "serve")
60
+ await assertProfileAgentsRootUnique(profile, home, homedir());
61
+ applyProfileToEnv(profile, process.env);
62
+ }
56
63
  // 命令行参数优先于环境变量,填回 env 供 loadConfig 读取
57
64
  if (values["server-url"])
58
65
  process.env.CREW_SERVER_URL = values["server-url"];
@@ -72,8 +79,8 @@ async function main() {
72
79
  }
73
80
  if (cmd === "serve") {
74
81
  process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
75
- serve(config);
76
- await new Promise(() => { }); // 常驻,直到被 kill
82
+ const service = serve(config);
83
+ await runServeLifecycle(service);
77
84
  return;
78
85
  }
79
86
  if (!values.agent || !values.channel) {
@@ -93,6 +100,14 @@ async function main() {
93
100
  process.exit(result.exitCode);
94
101
  }
95
102
  main().catch((e) => {
96
- process.stderr.write(`crew-daemon: ${e.message}\n`);
97
- process.exit(1);
103
+ const message = e instanceof ProfileAgentsRootConflictError
104
+ ? formatDaemonText(detectDaemonLang(), PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, {
105
+ profile: e.profile,
106
+ conflict: e.conflict,
107
+ agentsRoot: e.agentsRoot,
108
+ command: e.command,
109
+ })
110
+ : e.message;
111
+ process.stderr.write(`crew-daemon: ${message}\n`);
112
+ process.exitCode = 1;
98
113
  });
@@ -27,7 +27,9 @@ export async function readOriginDecisionFile(path) {
27
27
  }
28
28
  }
29
29
  export function shouldRetryOriginDecision(wakeOrigin, decision, attempt) {
30
- return wakeOrigin === "wecom" && decision?.decision !== "reply" && attempt === 0;
30
+ if (attempt !== 0 || wakeOrigin === undefined)
31
+ return false;
32
+ return wakeOrigin === "wecom" ? decision?.decision !== "reply" : decision === null;
31
33
  }
32
34
  export async function runWithOriginDecisionGuard(wakeOrigin, runAttempt) {
33
35
  const first = await runAttempt(0);
package/dist/prompt.js CHANGED
@@ -147,7 +147,10 @@ ${taskAndScheduleCommands}`;
147
147
  : ctx.wakeOrigin === "wecom"
148
148
  ? `
149
149
  - **本轮来自企微,结束本轮前必须给出一条完整回复**:确认、过程进展和内部协作仍用普通 \`crew message send\`,只写入 NowWork。最终只用一次 \`crew message send --reply-origin\`(同时带当前 channel/thread/content 参数)发送有实质内容的完整结果;不要把 \`--send-draft\` 当成草稿 ID 或外部回复开关。Server 仍会校验 thread 来源和机器人绑定,并在本轮未形成可交付内容时发送统一兜底回复。`
150
- : `
150
+ : ctx.wakeOrigin
151
+ ? `
152
+ - **本轮来自${ctx.wakeOrigin === "feishu" ? "飞书" : ctx.wakeOrigin},结束本轮前必须明确选择外部回复决策**:确认、过程进展和内部协作仍用普通 \`crew message send\`,只写入 NowWork。完整结果确实要回复外部会话时,用 \`crew message send --reply-origin\`(同时带当前 channel/thread/content 参数);判断无需回复时,用 \`crew message skip-origin --reason "简短原因"\`。两者必须选择一个;不要把 \`--send-draft\` 当成草稿 ID 或外部回复开关。Server 仍会校验 thread 来源和机器人绑定。`
153
+ : `
151
154
  - **本轮是 NowWork 内部唤醒**:普通 \`crew message send\` 只写入 NowWork。内部唤醒不得使用 \`--reply-origin\`,该参数只回答直接触发本轮的企微原消息。
152
155
  - **绑定会话主动通知**:用户明确要求同步,或最终结果有实质结论、变更或需群用户行动的阻塞时,才用一次 \`crew message send --notify-bound-im\`(同时带当前 channel/thread/content 参数)请求通知当前频道绑定的外部会话。Server 会校验绑定 owner 授权、绑定 Agent 和单轮边界;不能指定收件人。确认、进度、中间结果、无变化和重复内容一律留在 NowWork。`;
153
156
  const interactiveTaskRules = scheduled ? "" : `
package/dist/runner.js CHANGED
@@ -4,10 +4,12 @@ import { join } from "node:path";
4
4
  import { mintAgentToken } from "./token.js";
5
5
  import { buildSystemPrompt, buildWakePrompt, capMemoryForInject, capWorkLogForInject } from "./prompt.js";
6
6
  import { deliverScheduledReport, } from "./scheduled-report.js";
7
- import { executeLocal } from "./local-executor.js";
7
+ import { executeLocal, } from "./local-executor.js";
8
8
  import { ReasoningSchema } from "./execution-protocol.js";
9
9
  import { readOriginDecisionFile, resetOriginDecisionFile } from "./origin-decision.js";
10
10
  import { readBoundImDecisionFile, resetBoundImDecisionFile } from "./bound-im-decision.js";
11
+ import { launchSupervisedRuntime } from "./supervised-runtime.js";
12
+ import { awaitWithCancellation, } from "./runtime-cancellation.js";
11
13
  export { awaitExit, exitActivity, sanitizeEnvVars } from "./local-executor.js";
12
14
  const ICON = {
13
15
  init: "🟢", text: "💬", reading: "📖", sending: "📨", checking: "🔎",
@@ -18,24 +20,24 @@ function runtimeName(value) {
18
20
  return value;
19
21
  throw new Error(`unsupported runtime: ${value}`);
20
22
  }
21
- export async function runAgent(config, input, onActivity = defaultPrint, onConsole = () => { }) {
22
- const credential = await mintAgentToken(config.serverUrl, config.machineToken, input.handle, input.displayName, {
23
+ export async function runAgent(config, input, onActivity = defaultPrint, onConsole = () => { }, dependencies = {}) {
24
+ const credential = await awaitWithCancellation((dependencies.mintAgentToken ?? mintAgentToken)(config.serverUrl, config.machineToken, input.handle, input.displayName, {
23
25
  ...(input.wakeMessageId ? { wakeThreadRoot: input.wakeMessageId } : {}),
24
26
  ...(input.wakeContextUpToSeq === undefined ? {} : { wakeContextUpToSeq: input.wakeContextUpToSeq }),
25
27
  ...(input.runId ? { agentRunId: input.runId } : {}),
26
- });
28
+ }), dependencies.cancellation);
27
29
  const providerConfig = credential.config ?? {};
28
30
  const runtime = runtimeName(providerConfig.runtime ?? config.runtimeBin);
29
31
  const reasoning = ReasoningSchema.safeParse(providerConfig.reasoning);
30
32
  const baseWake = input.wake ?? buildWakePrompt(input.channelId);
31
33
  const executionId = input.runId ?? `legacy-${randomUUID()}`;
32
- const originDecisionFileName = input.wakeOrigin === "wecom"
34
+ const originDecisionFileName = input.wakeOrigin
33
35
  ? `.origin-decision-${executionId}.json`
34
36
  : null;
35
37
  const boundImDecisionFileName = input.scheduled?.externalNotificationPolicy === "agent_decides"
36
38
  ? `.bound-im-decision-${executionId}.json`
37
39
  : null;
38
- const local = await executeLocal({
40
+ const local = await (dependencies.executeLocal ?? executeLocal)({
39
41
  executionId,
40
42
  handle: input.handle,
41
43
  channelId: input.channelId,
@@ -86,7 +88,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
86
88
  ...(providerConfig.fastMode ? { CREW_FAST_MODE: "1" } : {}),
87
89
  ...(input.scheduled ? { CREW_SCHEDULE_OUTPUT_POLICY: input.scheduled.outputPolicy } : {}),
88
90
  ...(originDecisionFileName ? {
89
- CREW_WAKE_ORIGIN: "wecom",
91
+ CREW_WAKE_ORIGIN: input.wakeOrigin,
90
92
  CREW_ORIGIN_DECISION_FILE: originDecisionFileName,
91
93
  } : {}),
92
94
  ...(boundImDecisionFileName ? {
@@ -101,7 +103,10 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
101
103
  softTokens: config.sessionSoftTokens,
102
104
  maxTurns: config.sessionMaxTurns,
103
105
  },
104
- }, { onActivity, onConsole });
106
+ }, { onActivity, onConsole }, {
107
+ launchRuntime: dependencies.launchRuntime ?? ((request) => launchSupervisedRuntime(request, dependencies.startSupervisor, dependencies.cancellation, dependencies.platform)),
108
+ ...(dependencies.cancellation === undefined ? {} : { cancellation: dependencies.cancellation }),
109
+ });
105
110
  const activities = [...local.activities];
106
111
  if (!input.scheduled && (runtime === "codex" || runtime === "kimi")
107
112
  && local.exitCode === 0 && !local.sentViaCrew && local.finalText) {
@@ -169,7 +174,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
169
174
  ...(local.usage ? { usage: local.usage } : {}),
170
175
  ...(report ? { report } : {}),
171
176
  ...(boundImDecision ? { boundImDecision } : {}),
172
- ...(input.wakeOrigin === "wecom" ? {
177
+ ...(input.wakeOrigin ? {
173
178
  originDecision: originDecision?.decision ?? "missing",
174
179
  ...(originDecision?.decision === "silent" && originDecision.reason
175
180
  ? { originDecisionReason: originDecision.reason }