@nowcrew/daemon 0.5.19 → 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.
@@ -1,17 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import { DaemonToServerExecutionFrameSchema, ExecutionCompletedSchema, ExecutionRejectedSchema, ExecutionStartSchema, } from "./execution-protocol.js";
5
4
  import { JournalConflictError } from "./execution-journal.js";
6
5
  import { boundExecutionFrame } from "./execution-event-limit.js";
7
6
  import { mintAgentToken } from "./token.js";
8
7
  import { executeLocal, withLocalExecutionFacts, } from "./local-executor.js";
9
8
  import { startDormantSupervisor, } from "./execution-supervisor.js";
10
- import { buildClaudeArgs, CLAUDE_EFFORT_LEVELS } from "./runtimes/claude.js";
9
+ import { CLAUDE_EFFORT_LEVELS } from "./runtimes/claude.js";
11
10
  import { CODEX_EFFORT_LEVELS } from "./runtimes/codex.js";
12
11
  import { KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
13
12
  import { executionBackendCapability } from "./execution-backend.js";
14
13
  import { readBoundImDecisionFile, resetBoundImDecisionFile } from "./bound-im-decision.js";
14
+ import { RuntimeCancelledError } from "./runtime-cancellation.js";
15
+ import { supervisorLaunch } from "./supervised-runtime.js";
16
+ export { supervisorLaunch } from "./supervised-runtime.js";
15
17
  const ACTIVITY_KIND = {
16
18
  init: "working",
17
19
  text: "thinking",
@@ -183,70 +185,6 @@ function launchProviderConfig(config) {
183
185
  ...(config.description === undefined ? {} : { description: config.description }),
184
186
  };
185
187
  }
186
- export function supervisorLaunch(request) {
187
- const common = {
188
- wakePrompt: request.wakePrompt,
189
- dangerous: request.effectivePermission === "full_access",
190
- effectivePermission: request.effectivePermission,
191
- ...(request.model === undefined ? {} : { model: request.model }),
192
- ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
193
- };
194
- if (request.runtime === "claude") {
195
- return {
196
- command: request.bin,
197
- args: buildClaudeArgs({
198
- ...common,
199
- bin: request.bin,
200
- cwd: request.cwd,
201
- env: request.env,
202
- systemPromptPath: request.systemPromptPath,
203
- ...(request.sessionId === undefined ? {} : {
204
- sessionId: request.sessionId,
205
- resume: request.resume,
206
- }),
207
- }),
208
- cwd: request.cwd,
209
- env: request.env,
210
- };
211
- }
212
- if (request.runtime === "codex") {
213
- return {
214
- command: process.execPath,
215
- args: [
216
- fileURLToPath(new URL("./runtimes/codex-app-server-runner.js", import.meta.url)),
217
- "--bin", request.bin,
218
- ],
219
- cwd: request.cwd,
220
- env: request.env,
221
- stdinText: JSON.stringify({
222
- systemPrompt: request.systemPrompt,
223
- wakePrompt: request.wakePrompt,
224
- effectivePermission: request.effectivePermission,
225
- ...(request.model === undefined ? {} : { model: request.model }),
226
- ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
227
- ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
228
- ...(request.imagePaths === undefined ? {} : { imagePaths: request.imagePaths }),
229
- resume: request.resume,
230
- }),
231
- };
232
- }
233
- if (request.effectivePermission !== "full_access") {
234
- throw new Error(`Kimi ACP cannot enforce ${request.effectivePermission} permission`);
235
- }
236
- return {
237
- command: process.execPath,
238
- args: [
239
- fileURLToPath(new URL("./runtimes/kimi-acp-runner.js", import.meta.url)),
240
- "--bin", request.bin,
241
- ...(request.model === undefined ? [] : ["--model", request.model]),
242
- ...(request.sessionId === undefined ? [] : ["--session", request.sessionId]),
243
- ...(request.resume ? ["--resume"] : []),
244
- ],
245
- cwd: request.cwd,
246
- env: request.env,
247
- stdinText: `${request.systemPrompt}\n\n${request.wakePrompt}`,
248
- };
249
- }
250
188
  function rejection(executionId, reason, message, at) {
251
189
  return ExecutionRejectedSchema.parse({
252
190
  type: "execution:rejected",
@@ -494,7 +432,7 @@ export async function runExecution(config, input, dependencies) {
494
432
  catch { /* best-effort console omitted when its envelope cannot fit */ }
495
433
  },
496
434
  } : {}),
497
- ...(spec.context.externalResponseSessionId ? {
435
+ ...(spec.context.externalResponseSessionId || spec.context.answerStream ? {
498
436
  onExternalOutput: (text) => {
499
437
  const frame = DaemonToServerExecutionFrameSchema.parse({
500
438
  type: "execution:output",
@@ -513,6 +451,7 @@ export async function runExecution(config, input, dependencies) {
513
451
  } : {}),
514
452
  };
515
453
  const localDependencies = {
454
+ ...(dependencies.cancellation === undefined ? {} : { cancellation: dependencies.cancellation }),
516
455
  launchRuntime: async (request) => {
517
456
  if (launchClosed || dependencies.cancellation?.isRequested())
518
457
  throw new ExecutionCancelledError();
@@ -521,27 +460,42 @@ export async function runExecution(config, input, dependencies) {
521
460
  launchAttempts.add(launchSettled);
522
461
  try {
523
462
  const processStartedAt = now().toISOString();
524
- const guarded = await dependencies.journal.startGuarded(spec.executionId, processStartedAt, () => startSupervisor(supervisorLaunch(request)));
463
+ const launchControl = { cancel: null };
464
+ const guarded = await dependencies.journal.startGuarded(spec.executionId, processStartedAt, () => startSupervisor(supervisorLaunch(request)), {
465
+ beforeRelease: ({ entry, handle, abort }) => {
466
+ supervisorState.active = handle;
467
+ let stopPromise = null;
468
+ let releaseStarted = false;
469
+ const stopOnce = (operation) => {
470
+ if (stopPromise === null) {
471
+ try {
472
+ stopPromise = Promise.resolve(operation());
473
+ }
474
+ catch (error) {
475
+ stopPromise = Promise.reject(error);
476
+ }
477
+ }
478
+ return stopPromise;
479
+ };
480
+ launchControl.cancel = () => stopOnce(releaseStarted ? handle.cancel : abort);
481
+ supervisorState.abortOnce = () => stopOnce(abort);
482
+ dependencies.cancellation?.register(launchControl.cancel);
483
+ startedAt = entry.processStartedAt ?? processStartedAt;
484
+ if (dependencies.cancellation?.isRequested()) {
485
+ return dependencies.cancellation.waitForStop().then(() => {
486
+ throw new ExecutionCancelledError();
487
+ });
488
+ }
489
+ releaseStarted = true;
490
+ },
491
+ });
525
492
  if (guarded.kind !== "started") {
526
493
  throw new Error(`Execution became ${guarded.entry.state} before local launch`);
527
494
  }
528
- supervisorState.active = guarded.handle;
529
- let stopPromise = null;
530
- const stopOnce = (operation) => {
531
- if (stopPromise === null) {
532
- try {
533
- stopPromise = Promise.resolve(operation());
534
- }
535
- catch (error) {
536
- stopPromise = Promise.reject(error);
537
- }
538
- }
539
- return stopPromise;
540
- };
541
- const cancelOnce = () => stopOnce(guarded.handle.cancel);
542
- supervisorState.abortOnce = () => stopOnce(guarded.handle.abort);
543
- dependencies.cancellation?.register(cancelOnce);
544
- startedAt = guarded.entry.processStartedAt ?? processStartedAt;
495
+ if (launchControl.cancel === null) {
496
+ throw new Error("Execution launch cancellation gate was not installed");
497
+ }
498
+ const installedCancel = launchControl.cancel;
545
499
  await reportBestEffort(dependencies.report, boundExecutionFrame(DaemonToServerExecutionFrameSchema.parse({
546
500
  type: "execution:started",
547
501
  protocolVersion: 1,
@@ -552,14 +506,14 @@ export async function runExecution(config, input, dependencies) {
552
506
  timeout = setTimeout(() => {
553
507
  timedOut = true;
554
508
  try {
555
- void cancelOnce().catch(rejectCancellationFailure);
509
+ void installedCancel().catch(rejectCancellationFailure);
556
510
  }
557
511
  catch (error) {
558
512
  rejectCancellationFailure(error);
559
513
  }
560
514
  }, effectiveTimeoutMs);
561
515
  }
562
- return guarded.handle;
516
+ return { ...guarded.handle, cancel: installedCancel };
563
517
  }
564
518
  finally {
565
519
  launchAttempts.delete(launchSettled);
@@ -608,10 +562,10 @@ export async function runExecution(config, input, dependencies) {
608
562
  maxTurns: config.sessionMaxTurns,
609
563
  },
610
564
  };
611
- const result = await cancellable(Promise.race([
565
+ const result = await Promise.race([
612
566
  execute(localInput, callbacks, localDependencies),
613
567
  cancellationFailure,
614
- ]), dependencies.cancellation);
568
+ ]);
615
569
  if (timeout !== undefined)
616
570
  clearTimeout(timeout);
617
571
  const finishedAt = now().toISOString();
@@ -652,6 +606,9 @@ export async function runExecution(config, input, dependencies) {
652
606
  ...(!spec.reporting.captureFinal || result.finalText === null
653
607
  ? {}
654
608
  : { finalText: result.finalText }),
609
+ ...(spec.context.answerStream && result.exitCode === 0 && result.externalAnswer
610
+ ? { externalAnswer: result.externalAnswer }
611
+ : {}),
655
612
  ...(result.usage === undefined ? {} : { usage: result.usage }),
656
613
  startedAt,
657
614
  finishedAt,
@@ -660,7 +617,8 @@ export async function runExecution(config, input, dependencies) {
660
617
  catch (error) {
661
618
  if (timeout !== undefined)
662
619
  clearTimeout(timeout);
663
- if (error instanceof ExecutionCancelledError) {
620
+ const cancelled = error instanceof ExecutionCancelledError || error instanceof RuntimeCancelledError;
621
+ if (cancelled) {
664
622
  await closeLaunchGate();
665
623
  await dependencies.cancellation?.waitForStop();
666
624
  }
@@ -673,7 +631,7 @@ export async function runExecution(config, input, dependencies) {
673
631
  throw new AggregateError([error, abortError], `Failed to stop the execution supervisor: ${detail}`);
674
632
  }
675
633
  }
676
- completion = error instanceof ExecutionCancelledError
634
+ completion = cancelled
677
635
  ? ExecutionCompletedSchema.parse({
678
636
  type: "execution:completed",
679
637
  protocolVersion: 1,
@@ -44,6 +44,55 @@ async function waitForProcessGroupExit(pid, timeoutMs) {
44
44
  await new Promise((resolve) => setTimeout(resolve, PROCESS_GROUP_POLL_MS));
45
45
  }
46
46
  }
47
+ async function processGroupExists(pid) {
48
+ try {
49
+ process.kill(-pid, 0);
50
+ return true;
51
+ }
52
+ catch (error) {
53
+ const code = error.code;
54
+ if (code === "ESRCH")
55
+ return false;
56
+ if (code === "EPERM")
57
+ return true;
58
+ throw error;
59
+ }
60
+ }
61
+ async function signalOwnedTreeIfPresent(pid, signal, platform, signalTree) {
62
+ try {
63
+ await signalTree(pid, signal, platform);
64
+ }
65
+ catch (error) {
66
+ if (error.code !== "ESRCH")
67
+ throw error;
68
+ }
69
+ }
70
+ async function terminateAndConfirmOwnedTree(pid, platform, timeoutMs, supervisorClosed, signalTree) {
71
+ const waitUntilStopped = () => platform === "win32"
72
+ ? waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), timeoutMs, pid)
73
+ : waitForProcessGroupExit(pid, timeoutMs);
74
+ let termError;
75
+ try {
76
+ await signalOwnedTreeIfPresent(pid, "SIGTERM", platform, signalTree);
77
+ await waitUntilStopped();
78
+ return;
79
+ }
80
+ catch (error) {
81
+ termError = error;
82
+ }
83
+ try {
84
+ await signalOwnedTreeIfPresent(pid, "SIGKILL", platform, signalTree);
85
+ await waitUntilStopped();
86
+ }
87
+ catch (killError) {
88
+ throw new AggregateError([termError, killError], "Supervisor process-tree termination failed");
89
+ }
90
+ }
91
+ async function confirmOrTerminateOwnedTree(pid, platform, timeoutMs, supervisorClosed, signalTree) {
92
+ if (platform !== "win32" && !(await processGroupExists(pid)))
93
+ return;
94
+ await terminateAndConfirmOwnedTree(pid, platform, timeoutMs, supervisorClosed, signalTree);
95
+ }
47
96
  async function withTimeout(promise, timeoutMs, phase) {
48
97
  let timer;
49
98
  try {
@@ -86,9 +135,13 @@ export async function signalSupervisorTree(pid, signal, platform = process.platf
86
135
  }
87
136
  export async function startDormantSupervisor(launch, options = {}) {
88
137
  const platform = options.platform ?? process.platform;
89
- const backend = executionBackendCapability(platform);
90
- if (!backend.supported)
91
- throw new Error(backend.reason);
138
+ const ownershipMode = options.ownershipMode ?? "durable";
139
+ if (ownershipMode === "durable") {
140
+ const backend = executionBackendCapability(platform);
141
+ if (!backend.supported)
142
+ throw new Error(backend.reason);
143
+ }
144
+ const signalTree = options.signalTree ?? signalSupervisorTree;
92
145
  const childEntry = options.childEntry
93
146
  ?? fileURLToPath(new URL("./execution-supervisor-child.js", import.meta.url));
94
147
  const abortTimeoutMs = options.abortTimeoutMs ?? DEFAULT_ABORT_TIMEOUT_MS;
@@ -118,12 +171,16 @@ export async function startDormantSupervisor(launch, options = {}) {
118
171
  ...(supervisorSpawnError === undefined && signal !== null ? { terminationSignal: signal } : {}),
119
172
  }));
120
173
  });
174
+ const supervisorClosed = new Promise((resolve) => child.once("close", () => resolve()));
175
+ let treeStopPromise = null;
176
+ const ensureTreeStopped = () => {
177
+ treeStopPromise ??= confirmOrTerminateOwnedTree(pid, platform, abortTimeoutMs, supervisorClosed, signalTree);
178
+ return treeStopPromise;
179
+ };
121
180
  const exit = supervisorExit.then(async (result) => {
122
- if (platform !== "win32")
123
- await waitForProcessGroupExit(pid, abortTimeoutMs);
181
+ await ensureTreeStopped();
124
182
  return result;
125
183
  });
126
- const supervisorClosed = new Promise((resolve) => child.once("close", () => resolve()));
127
184
  let readyResolve;
128
185
  let readyReject;
129
186
  const ready = new Promise((resolve, reject) => {
@@ -161,41 +218,30 @@ export async function startDormantSupervisor(launch, options = {}) {
161
218
  const abort = async () => {
162
219
  if (child.exitCode !== null || child.signalCode !== null) {
163
220
  await supervisorClosed;
221
+ await ensureTreeStopped();
164
222
  return;
165
223
  }
166
- try {
167
- await signalSupervisorTree(pid, "SIGTERM", platform);
168
- }
169
- catch (error) {
170
- const code = error instanceof Error && "code" in error ? error.code : undefined;
171
- if (code !== "ESRCH")
172
- throw error;
173
- }
174
- try {
175
- await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
176
- }
177
- catch (error) {
178
- try {
179
- await signalSupervisorTree(pid, "SIGKILL", platform);
180
- }
181
- catch (killError) {
182
- const code = killError instanceof Error && "code" in killError ? killError.code : undefined;
183
- if (code !== "ESRCH")
184
- throw new AggregateError([error, killError], "Supervisor abort failed");
185
- }
186
- await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
187
- }
224
+ await ensureTreeStopped();
225
+ await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
226
+ await ensureTreeStopped();
188
227
  };
189
228
  const cancel = async () => {
190
229
  if (child.exitCode !== null || child.signalCode !== null) {
191
230
  await supervisorClosed;
231
+ await ensureTreeStopped();
192
232
  return;
193
233
  }
194
234
  try {
195
235
  await new Promise((resolve, reject) => {
196
236
  child.send({ type: "abort" }, (error) => error === null ? resolve() : reject(error));
197
237
  });
198
- await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
238
+ if (platform === "win32") {
239
+ await ensureTreeStopped();
240
+ }
241
+ else {
242
+ await waitForExit(supervisorClosed.then(() => ({ exitCode: 0 })), abortTimeoutMs, pid);
243
+ await ensureTreeStopped();
244
+ }
199
245
  }
200
246
  catch {
201
247
  await abort();
@@ -218,9 +264,8 @@ export async function startDormantSupervisor(launch, options = {}) {
218
264
  });
219
265
  throw error;
220
266
  }
221
- return {
267
+ const handle = {
222
268
  pid,
223
- parentExitGuard: "pipe-eof",
224
269
  stdout: child.stdout,
225
270
  stderr: child.stderr,
226
271
  exit,
@@ -249,4 +294,7 @@ export async function startDormantSupervisor(launch, options = {}) {
249
294
  abort,
250
295
  cancel,
251
296
  };
297
+ return ownershipMode === "durable"
298
+ ? { ...handle, ownershipMode, parentExitGuard: "pipe-eof" }
299
+ : { ...handle, ownershipMode };
252
300
  }
@@ -84,3 +84,31 @@ export function stripExternalAnswerMarkers(value) {
84
84
  }
85
85
  return (sections.length > 0 ? sections.join("") : value).trim();
86
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
  }
@@ -11,9 +11,10 @@ import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./
11
11
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
12
12
  import { toConsoleLines } from "./console.js";
13
13
  import { capMemoryForInject, capWorkLogForInject } from "./prompt.js";
14
- import { decodeExternalOutputEvent, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
15
- import { cleanupMaterializedAttachments as cleanupAttachments, materializeAttachments, } from "./attachments.js";
14
+ import { decodeExternalOutputEvent, extractExternalAnswer, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
15
+ import { cleanupMaterializedAttachments as cleanupAttachments, executionAttachmentDirectory, materializeAttachments, } from "./attachments.js";
16
16
  import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilities.js";
17
+ import { awaitWithCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
17
18
  function truncateUtf8(value, maxBytes) {
18
19
  if (maxBytes <= 0)
19
20
  return "";
@@ -138,20 +139,22 @@ function resolvePrompt(prompt, context) {
138
139
  return typeof prompt === "string" ? prompt : prompt(context);
139
140
  }
140
141
  const nativeSessionLeaseTails = new Map();
141
- async function withKeyedLease(key, operation) {
142
+ async function withKeyedLease(key, operation, cancellation) {
142
143
  const predecessor = nativeSessionLeaseTails.get(key) ?? Promise.resolve();
143
144
  let release;
144
145
  const current = new Promise((resolve) => { release = resolve; });
145
146
  const tail = predecessor.then(() => current);
146
147
  nativeSessionLeaseTails.set(key, tail);
147
- await predecessor;
148
148
  try {
149
+ await awaitWithCancellation(predecessor, cancellation);
149
150
  return await operation();
150
151
  }
151
152
  finally {
152
153
  release();
153
- if (nativeSessionLeaseTails.get(key) === tail)
154
- nativeSessionLeaseTails.delete(key);
154
+ void tail.then(() => {
155
+ if (nativeSessionLeaseTails.get(key) === tail)
156
+ nativeSessionLeaseTails.delete(key);
157
+ });
155
158
  }
156
159
  }
157
160
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
@@ -167,14 +170,14 @@ export async function executeLocal(input, callbacks = {}, dependencies = {}) {
167
170
  input.keyMode ?? "legacy",
168
171
  input.resumeKey ?? input.taskKey ?? "",
169
172
  ]);
170
- return withKeyedLease(leaseKey, () => executeLocalUnlocked(input, callbacks, dependencies));
173
+ return withKeyedLease(leaseKey, () => executeLocalUnlocked(input, callbacks, dependencies), dependencies.cancellation);
171
174
  }
172
175
  async function executeLocalUnlocked(input, callbacks, dependencies) {
173
176
  const providerConfig = input.launch.providerConfig ?? {};
174
177
  const { runtime } = input;
175
178
  const currentModel = runtime.model ?? null;
176
179
  const providerFp = providerFingerprint(runtime.name, providerConfig);
177
- const workspace = await prepareWorkspace({
180
+ const workspace = await awaitWithCancellation(prepareWorkspace({
178
181
  agentsRoot: input.launch.agentsRoot,
179
182
  handle: input.handle,
180
183
  cliPath: input.launch.cliPath,
@@ -183,8 +186,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
183
186
  ...(input.taskKey === undefined ? {} : { taskKey: input.taskKey }),
184
187
  ...(input.resumeKey === undefined ? {} : { resumeKey: input.resumeKey }),
185
188
  ...(input.launch.description ? { description: input.launch.description } : {}),
186
- });
189
+ }), dependencies.cancellation);
187
190
  let materialized = null;
191
+ let knownAttachmentDirectory = null;
188
192
  try {
189
193
  const supportsNativeResume = runtime.name === "claude"
190
194
  || (dependencies.launchRuntime !== undefined && runtimeCapability(runtime.name).nativeResume);
@@ -208,17 +212,38 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
208
212
  };
209
213
  const systemPrompt = resolvePrompt(input.systemPrompt, promptContext);
210
214
  if (input.attachments && input.attachments.length > 0) {
211
- materialized = await (dependencies.materializeAttachments ?? materializeAttachments)({
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)({
212
220
  serverUrl: input.launch.serverUrl,
213
221
  token: input.launch.token,
214
222
  runDir: workspace.runDir,
215
223
  executionId: input.executionId,
216
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);
217
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
+ }
218
243
  }
219
244
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
220
245
  const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
221
- await writeFile(workspace.systemPromptPath, systemPrompt, "utf8");
246
+ await awaitWithCancellation(writeFile(workspace.systemPromptPath, systemPrompt, "utf8"), dependencies.cancellation);
222
247
  const baseEnv = {
223
248
  ...process.env,
224
249
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -242,6 +267,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
242
267
  };
243
268
  const childEnv = applyProviderEnv(baseEnv, runtime.name, providerConfig, workspace.homeDir);
244
269
  const launchRuntime = dependencies.launchRuntime ?? launchLegacyRuntime;
270
+ if (dependencies.cancellation?.isRequested())
271
+ throw new RuntimeCancelledError();
245
272
  const child = await launchRuntime({
246
273
  runtime: runtime.name,
247
274
  bin: runtime.name,
@@ -259,6 +286,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
259
286
  ? { imagePaths: attachmentPlan.nativeImagePaths }
260
287
  : {}),
261
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
+ }
262
296
  const activities = [];
263
297
  let sessionId = launchSessionId;
264
298
  let usage;
@@ -301,7 +335,17 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
301
335
  process.stderr.write(data);
302
336
  stderrTail = (stderrTail + String(data)).slice(-STDERR_TAIL_CAP);
303
337
  });
304
- 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;
305
349
  const errorTail = [
306
350
  stderrTail.trim(),
307
351
  spawnError,
@@ -343,17 +387,24 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
343
387
  finalText: input.captureFinal && finalText !== null
344
388
  ? stripExternalAnswerMarkers(finalText)
345
389
  : null,
390
+ externalAnswer: input.captureFinal && finalText !== null
391
+ ? extractExternalAnswer(finalText)
392
+ : null,
346
393
  sentViaCrew,
347
394
  };
348
395
  }
349
396
  finally {
350
- if (materialized) {
397
+ const attachmentDirectories = new Set([
398
+ ...(knownAttachmentDirectory === null ? [] : [knownAttachmentDirectory]),
399
+ ...(materialized === null ? [] : [materialized.directory]),
400
+ ]);
401
+ for (const directory of attachmentDirectories) {
351
402
  try {
352
- await (dependencies.cleanupMaterializedAttachments ?? cleanupAttachments)(materialized.directory);
403
+ await (dependencies.cleanupMaterializedAttachments ?? cleanupAttachments)(directory);
353
404
  }
354
405
  catch (error) {
355
406
  const detail = error instanceof Error ? error.message : String(error);
356
- process.stderr.write(`[execution] failed to remove attachments ${materialized.directory}: ${detail}\n`);
407
+ process.stderr.write(`[execution] failed to remove attachments ${directory}: ${detail}\n`);
357
408
  }
358
409
  }
359
410
  try {
@@ -24,6 +24,7 @@ export const DAEMON_CAPABILITIES = [
24
24
  "execution_telemetry_ack_v1",
25
25
  "execution_external_output_v1",
26
26
  "execution_attachments_v1",
27
+ "execution_answer_stream_v1",
27
28
  ];
28
29
  export const EXECUTION_PROTOCOL = Object.freeze({ min: 1, max: 1 });
29
30
  /** 候选 runtime CLI:展示名 → 可执行文件名。 */