@nowcrew/daemon 0.5.18 → 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.
package/README.md CHANGED
@@ -80,6 +80,25 @@ the local workspace/environment, and launches only a built-in runtime adapter (`
80
80
  `kimi`). It does not decide collaboration rules, scheduled output policy, fallback delivery, or thread
81
81
  behavior. Those are server responsibilities.
82
82
 
83
+ The stable adapters keep prompts out of provider argv wherever the provider protocol allows it:
84
+
85
+ | Runtime | Transport | System/wake input | Native resume |
86
+ | --- | --- | --- | --- |
87
+ | Claude | stream-json CLI | daemon-owned system prompt file | CLI session id |
88
+ | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume` |
89
+ | Kimi | ACP over stdio | `session/prompt` | `session/resume` (`session/load` compatibility fallback) |
90
+
91
+ Kimi ACP currently requires a Kimi account even when the CLI has a working custom provider. Only for
92
+ the explicit `Authentication required` response, the adapter retries once through Kimi's stream-json
93
+ CLI and carries the same native session id; unrelated ACP failures stay failures. The CLI fallback is
94
+ subject to the Windows UTF-16 argv guard, while protocol-v1 itself remains disabled on Windows until
95
+ durable Job Object ownership is available.
96
+
97
+ Codex and Kimi sessions use the same bounded per-task persistence and keyed lease as Claude when they
98
+ run through protocol v1. A first-progress watchdog covers a provider that accepts a turn but remains
99
+ semantically silent; after the first semantic event, the execution's configured total timeout remains
100
+ authoritative so a legitimate long-running tool is not killed for quiet output.
101
+
83
102
  Protocol support and limits are advertised in `machine:hello`. `runtimes` reports every recognized CLI
84
103
  found on `PATH`; `executionRuntimes` separately reports the installed CLIs backed by a complete built-in
85
104
  adapter. The server must use the latter for admission and treats the former as diagnostic inventory only.
@@ -104,6 +123,10 @@ Before accepting work, the daemon writes a local journal entry under:
104
123
  running supervisors are identity-checked and terminated before interruption is reported. A lock prevents
105
124
  two daemon processes from sharing one journal.
106
125
 
126
+ Protocol v1 is advertised only when the platform has a durable process-tree backend. POSIX uses an
127
+ owned process group. Native Windows intentionally fails admission until a Job Object backend has passed
128
+ crash-cleanup tests; `taskkill /T` is not treated as equivalent ownership.
129
+
107
130
  ## Local Policy
108
131
 
109
132
  Useful environment controls:
@@ -0,0 +1,161 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { basename, extname, resolve, sep } from "node:path";
3
+ export const ATTACHMENT_MAX_FILE_BYTES = 25 * 1024 * 1024;
4
+ export const ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
5
+ function truncateUtf8(value, maxBytes) {
6
+ let bytes = 0;
7
+ let output = "";
8
+ for (const character of value) {
9
+ const size = Buffer.byteLength(character, "utf8");
10
+ if (bytes + size > maxBytes)
11
+ break;
12
+ output += character;
13
+ bytes += size;
14
+ }
15
+ return output;
16
+ }
17
+ function safeFilename(value) {
18
+ const leaf = basename(value.replace(/\\/gu, "/"))
19
+ .replace(/[\u0000-\u001f\u007f]/gu, "_")
20
+ .replace(/^\.+/u, "")
21
+ .trim();
22
+ return truncateUtf8(leaf, 180) || "attachment";
23
+ }
24
+ function collisionName(filename, index) {
25
+ if (index === 1)
26
+ return filename;
27
+ const extension = extname(filename);
28
+ const stem = extension ? filename.slice(0, -extension.length) : filename;
29
+ return `${stem}-${index}${extension}`;
30
+ }
31
+ function containedPath(directory, filename) {
32
+ const target = resolve(directory, filename);
33
+ if (!target.startsWith(`${resolve(directory)}${sep}`)) {
34
+ throw new Error("Attachment path escapes the execution directory");
35
+ }
36
+ return target;
37
+ }
38
+ function checkedRedirect(value, base) {
39
+ const url = new URL(value, base);
40
+ if (url.protocol !== "https:" || url.username || url.password) {
41
+ throw new Error("Attachment redirects must use credential-free HTTPS URLs");
42
+ }
43
+ return url;
44
+ }
45
+ async function readBounded(response, maxBytes) {
46
+ const declared = Number(response.headers.get("content-length") ?? "");
47
+ if (Number.isFinite(declared) && declared > maxBytes) {
48
+ throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
49
+ }
50
+ if (!response.body)
51
+ throw new Error("Attachment response body is empty");
52
+ const reader = response.body.getReader();
53
+ const chunks = [];
54
+ let size = 0;
55
+ while (true) {
56
+ const part = await reader.read();
57
+ if (part.done)
58
+ break;
59
+ const chunk = Buffer.from(part.value);
60
+ size += chunk.length;
61
+ if (size > maxBytes) {
62
+ await reader.cancel();
63
+ throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
64
+ }
65
+ chunks.push(chunk);
66
+ }
67
+ return Buffer.concat(chunks, size);
68
+ }
69
+ async function downloadAttachment(input) {
70
+ const initial = new URL(`/agent/attachments/${encodeURIComponent(input.attachment.id)}/download`, input.serverUrl);
71
+ if (!["http:", "https:"].includes(initial.protocol)
72
+ || initial.username || initial.password) {
73
+ throw new Error("Invalid NowWork attachment download URL");
74
+ }
75
+ let current = initial;
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs);
78
+ try {
79
+ let response = await input.fetchImpl(current, {
80
+ redirect: "manual",
81
+ headers: { authorization: `Bearer ${input.token}` },
82
+ signal: controller.signal,
83
+ });
84
+ for (let redirects = 0; [301, 302, 303, 307, 308].includes(response.status); redirects += 1) {
85
+ if (redirects >= input.maxRedirects)
86
+ throw new Error("Attachment redirect limit exceeded");
87
+ const location = response.headers.get("location");
88
+ if (!location)
89
+ throw new Error("Attachment redirect is missing a location");
90
+ current = checkedRedirect(location, current);
91
+ response = await input.fetchImpl(current, { redirect: "manual", signal: controller.signal });
92
+ }
93
+ if (!response.ok)
94
+ throw new Error(`Attachment download failed with status ${response.status}`);
95
+ const data = await readBounded(response, input.maxFileBytes);
96
+ if (data.length !== input.attachment.sizeBytes) {
97
+ throw new Error(`Attachment size mismatch: expected ${input.attachment.sizeBytes}, received ${data.length}`);
98
+ }
99
+ return data;
100
+ }
101
+ catch (error) {
102
+ if (controller.signal.aborted) {
103
+ throw new Error(`Attachment download timed out after ${input.timeoutMs}ms`);
104
+ }
105
+ throw error;
106
+ }
107
+ finally {
108
+ clearTimeout(timer);
109
+ }
110
+ }
111
+ export async function materializeAttachments(input) {
112
+ const maxFileBytes = input.maxFileBytes ?? ATTACHMENT_MAX_FILE_BYTES;
113
+ const maxTotalBytes = input.maxTotalBytes ?? ATTACHMENT_MAX_TOTAL_BYTES;
114
+ const total = input.attachments.reduce((sum, attachment) => sum + attachment.sizeBytes, 0);
115
+ if (input.attachments.some((attachment) => attachment.sizeBytes > maxFileBytes)
116
+ || total > maxTotalBytes) {
117
+ throw new Error("Attachment metadata exceeds the configured byte limit");
118
+ }
119
+ const attachmentsRoot = resolve(input.runDir, "attachments");
120
+ const executionKey = input.executionId.replace(/[^A-Za-z0-9._-]/gu, "_");
121
+ const directory = resolve(attachmentsRoot, executionKey);
122
+ if (!directory.startsWith(`${attachmentsRoot}${sep}`)) {
123
+ throw new Error("Attachment directory escapes the run directory");
124
+ }
125
+ await rm(directory, { recursive: true, force: true });
126
+ await mkdir(directory, { recursive: true, mode: 0o700 });
127
+ const used = new Set();
128
+ const materialized = [];
129
+ try {
130
+ for (const attachment of input.attachments) {
131
+ const base = safeFilename(attachment.filename);
132
+ let collision = 1;
133
+ let filename = collisionName(base, collision);
134
+ while (used.has(filename.toLowerCase())) {
135
+ collision += 1;
136
+ filename = collisionName(base, collision);
137
+ }
138
+ used.add(filename.toLowerCase());
139
+ const path = containedPath(directory, filename);
140
+ const data = await downloadAttachment({
141
+ serverUrl: input.serverUrl,
142
+ token: input.token,
143
+ attachment,
144
+ fetchImpl: input.fetchImpl ?? fetch,
145
+ maxFileBytes,
146
+ maxRedirects: input.maxRedirects ?? 3,
147
+ timeoutMs: input.timeoutMs ?? 10_000,
148
+ });
149
+ await writeFile(path, data, { flag: "wx", mode: 0o600 });
150
+ materialized.push({ ...attachment, filename, path });
151
+ }
152
+ return { directory, attachments: materialized };
153
+ }
154
+ catch (error) {
155
+ await rm(directory, { recursive: true, force: true });
156
+ throw error;
157
+ }
158
+ }
159
+ export async function cleanupMaterializedAttachments(directory) {
160
+ await rm(directory, { recursive: true, force: true });
161
+ }
@@ -36,7 +36,7 @@ export function boundExecutionFrame(input, maxBytes) {
36
36
  if (input.type === "execution:activity") {
37
37
  frame = withBoundedString(frame, "detail", maxBytes, false);
38
38
  }
39
- else if (input.type === "execution:console") {
39
+ else if (input.type === "execution:console" || input.type === "execution:output") {
40
40
  frame = withBoundedString(frame, "text", maxBytes, false);
41
41
  }
42
42
  else if (input.type === "execution:rejected") {
@@ -48,7 +48,7 @@ export const LegacyAgentStartSchema = z.object({
48
48
  content: z.string().optional(),
49
49
  senderHandle: z.string().optional(),
50
50
  threadId: z.string().optional(),
51
- origin: z.enum(["wecom"]).optional(),
51
+ origin: z.enum(["wecom", "feishu"]).optional(),
52
52
  }).passthrough().optional(),
53
53
  scheduledRun: z.object({
54
54
  jobId: z.string().min(1),
@@ -85,6 +85,12 @@ export const ConsoleStreamSchema = z.enum([
85
85
  "result",
86
86
  "error",
87
87
  ]);
88
+ export const ExecutionAttachmentSchema = z.object({
89
+ id: z.string().min(1).max(200),
90
+ filename: z.string().min(1).max(255),
91
+ mime: z.string().min(1).max(200),
92
+ sizeBytes: z.number().int().nonnegative().max(25 * 1024 * 1024),
93
+ }).strict();
88
94
  export const ExecutionStartSchema = z.object({
89
95
  type: z.literal("execution:start"),
90
96
  protocolVersion: ProtocolVersionSchema,
@@ -115,6 +121,8 @@ export const ExecutionStartSchema = z.object({
115
121
  channelId: z.string().min(1),
116
122
  threadId: z.string().min(1).optional(),
117
123
  wakeMessageId: z.string().min(1).optional(),
124
+ externalResponseSessionId: ExecutionIdSchema.optional(),
125
+ attachments: z.array(ExecutionAttachmentSchema).max(20).optional(),
118
126
  }).strict(),
119
127
  reporting: z.object({
120
128
  captureFinal: z.boolean(),
@@ -185,6 +193,15 @@ export const ExecutionConsoleSchema = z.object({
185
193
  seq: SequenceSchema,
186
194
  at: TimestampSchema,
187
195
  }).strict();
196
+ export const ExecutionOutputSchema = z.object({
197
+ type: z.literal("execution:output"),
198
+ protocolVersion: ProtocolVersionSchema,
199
+ executionId: ExecutionIdSchema,
200
+ channel: z.literal("external_answer"),
201
+ text: z.string().min(1),
202
+ seq: SequenceSchema,
203
+ at: TimestampSchema,
204
+ }).strict();
188
205
  export const ExecutionUsageSchema = z.object({
189
206
  inputTokens: TokenCountSchema,
190
207
  outputTokens: TokenCountSchema,
@@ -312,6 +329,7 @@ const RawDaemonToServerExecutionFrameSchema = z.discriminatedUnion("type", [
312
329
  ExecutionStartedSchema,
313
330
  ExecutionActivitySchema,
314
331
  ExecutionConsoleSchema,
332
+ ExecutionOutputSchema,
315
333
  RawExecutionCompletedSchema,
316
334
  ExecutionSnapshotSchema,
317
335
  ]);
@@ -8,7 +8,7 @@ import { mintAgentToken } from "./token.js";
8
8
  import { executeLocal, withLocalExecutionFacts, } from "./local-executor.js";
9
9
  import { startDormantSupervisor, } from "./execution-supervisor.js";
10
10
  import { buildClaudeArgs, CLAUDE_EFFORT_LEVELS } from "./runtimes/claude.js";
11
- import { buildCodexArgs, CODEX_EFFORT_LEVELS } from "./runtimes/codex.js";
11
+ import { CODEX_EFFORT_LEVELS } from "./runtimes/codex.js";
12
12
  import { KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
13
13
  import { executionBackendCapability } from "./execution-backend.js";
14
14
  import { readBoundImDecisionFile, resetBoundImDecisionFile } from "./bound-im-decision.js";
@@ -183,7 +183,7 @@ function launchProviderConfig(config) {
183
183
  ...(config.description === undefined ? {} : { description: config.description }),
184
184
  };
185
185
  }
186
- function supervisorLaunch(request) {
186
+ export function supervisorLaunch(request) {
187
187
  const common = {
188
188
  wakePrompt: request.wakePrompt,
189
189
  dangerous: request.effectivePermission === "full_access",
@@ -211,11 +211,23 @@ function supervisorLaunch(request) {
211
211
  }
212
212
  if (request.runtime === "codex") {
213
213
  return {
214
- command: request.bin,
215
- args: buildCodexArgs(common),
214
+ command: process.execPath,
215
+ args: [
216
+ fileURLToPath(new URL("./runtimes/codex-app-server-runner.js", import.meta.url)),
217
+ "--bin", request.bin,
218
+ ],
216
219
  cwd: request.cwd,
217
220
  env: request.env,
218
- stdinText: `${request.systemPrompt}\n\n${request.wakePrompt}`,
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
+ }),
219
231
  };
220
232
  }
221
233
  if (request.effectivePermission !== "full_access") {
@@ -227,6 +239,8 @@ function supervisorLaunch(request) {
227
239
  fileURLToPath(new URL("./runtimes/kimi-acp-runner.js", import.meta.url)),
228
240
  "--bin", request.bin,
229
241
  ...(request.model === undefined ? [] : ["--model", request.model]),
242
+ ...(request.sessionId === undefined ? [] : ["--session", request.sessionId]),
243
+ ...(request.resume ? ["--resume"] : []),
230
244
  ],
231
245
  cwd: request.cwd,
232
246
  env: request.env,
@@ -442,6 +456,7 @@ export async function runExecution(config, input, dependencies) {
442
456
  const providerConfig = launchProviderConfig(credential.config);
443
457
  let activitySequence = 0;
444
458
  let consoleSequence = 0;
459
+ let externalOutputSequence = 0;
445
460
  const callbacks = {
446
461
  ...(spec.reporting.streamActivity ? {
447
462
  onActivity: (activity) => {
@@ -479,6 +494,23 @@ export async function runExecution(config, input, dependencies) {
479
494
  catch { /* best-effort console omitted when its envelope cannot fit */ }
480
495
  },
481
496
  } : {}),
497
+ ...(spec.context.externalResponseSessionId ? {
498
+ onExternalOutput: (text) => {
499
+ const frame = DaemonToServerExecutionFrameSchema.parse({
500
+ type: "execution:output",
501
+ protocolVersion: 1,
502
+ executionId: spec.executionId,
503
+ channel: "external_answer",
504
+ text,
505
+ seq: externalOutputSequence++,
506
+ at: now().toISOString(),
507
+ });
508
+ try {
509
+ telemetry.enqueue(boundExecutionFrame(frame, config.executionLimits.maxEventBytes));
510
+ }
511
+ catch { /* final completion remains the authoritative repair */ }
512
+ },
513
+ } : {}),
482
514
  };
483
515
  const localDependencies = {
484
516
  launchRuntime: async (request) => {
@@ -543,6 +575,7 @@ export async function runExecution(config, input, dependencies) {
543
575
  taskKey: spec.workspace.taskKey,
544
576
  ...(spec.workspace.resumeKey === undefined ? {} : { resumeKey: spec.workspace.resumeKey }),
545
577
  ...(spec.context.wakeMessageId === undefined ? {} : { wakeMessageId: spec.context.wakeMessageId }),
578
+ ...(spec.context.attachments === undefined ? {} : { attachments: spec.context.attachments }),
546
579
  systemPrompt: withLocalExecutionFacts(spec.instructions.systemPrompt, config.executionLimits.maxPromptBytes
547
580
  - Buffer.byteLength(spec.instructions.wakePrompt, "utf8")),
548
581
  wakePrompt: spec.instructions.wakePrompt,
@@ -0,0 +1,86 @@
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
+ }
@@ -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,14 @@ 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, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
15
+ import { cleanupMaterializedAttachments as cleanupAttachments, materializeAttachments, } from "./attachments.js";
16
+ import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilities.js";
13
17
  function truncateUtf8(value, maxBytes) {
14
18
  if (maxBytes <= 0)
15
19
  return "";
@@ -118,6 +122,7 @@ async function launchLegacyRuntime(request) {
118
122
  return wrapChild(spawnCodex({
119
123
  ...common,
120
124
  wakePrompt: `${request.systemPrompt}\n\n${request.wakePrompt}`,
125
+ ...(request.imagePaths === undefined ? {} : { imagePaths: request.imagePaths }),
121
126
  }));
122
127
  }
123
128
  return wrapChild(spawnKimi({
@@ -150,7 +155,10 @@ async function withKeyedLease(key, operation) {
150
155
  }
151
156
  }
152
157
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
153
- if (input.runtime.name !== "claude" || !input.session.enabled) {
158
+ const stableProtocolRuntime = dependencies.launchRuntime !== undefined;
159
+ const supportsNativeResume = input.runtime.name === "claude"
160
+ || (stableProtocolRuntime && runtimeCapability(input.runtime.name).nativeResume);
161
+ if (!supportsNativeResume || !input.session.enabled) {
154
162
  return executeLocalUnlocked(input, callbacks, dependencies);
155
163
  }
156
164
  const leaseKey = JSON.stringify([
@@ -176,8 +184,10 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
176
184
  ...(input.resumeKey === undefined ? {} : { resumeKey: input.resumeKey }),
177
185
  ...(input.launch.description ? { description: input.launch.description } : {}),
178
186
  });
187
+ let materialized = null;
179
188
  try {
180
- const supportsNativeResume = runtime.name === "claude";
189
+ const supportsNativeResume = runtime.name === "claude"
190
+ || (dependencies.launchRuntime !== undefined && runtimeCapability(runtime.name).nativeResume);
181
191
  const prior = input.session.enabled && supportsNativeResume
182
192
  ? await readSession(workspace.sessionDir)
183
193
  : null;
@@ -197,13 +207,23 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
197
207
  nearBudget,
198
208
  };
199
209
  const systemPrompt = resolvePrompt(input.systemPrompt, promptContext);
200
- const wakePrompt = resolvePrompt(input.wakePrompt, promptContext);
210
+ if (input.attachments && input.attachments.length > 0) {
211
+ materialized = await (dependencies.materializeAttachments ?? materializeAttachments)({
212
+ serverUrl: input.launch.serverUrl,
213
+ token: input.launch.token,
214
+ runDir: workspace.runDir,
215
+ executionId: input.executionId,
216
+ attachments: input.attachments,
217
+ });
218
+ }
219
+ const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
220
+ const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
201
221
  await writeFile(workspace.systemPromptPath, systemPrompt, "utf8");
202
222
  const baseEnv = {
203
223
  ...process.env,
204
224
  ...sanitizeEnvVars(providerConfig.envVars),
205
225
  ...input.launch.systemEnv,
206
- PATH: `${workspace.crewDir}${delimiter}${process.env.PATH ?? ""}`,
226
+ PATH: `${workspace.crewDir}${delimiter}${augmentedPath()}`,
207
227
  CREW_SERVER_URL: input.launch.serverUrl,
208
228
  CREW_TOKEN: input.launch.token,
209
229
  CREW_CHANNEL: input.channelId,
@@ -235,6 +255,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
235
255
  ...(runtime.reasoning === undefined ? {} : { reasoning: runtime.reasoning }),
236
256
  ...(launchSessionId === null ? {} : { sessionId: launchSessionId }),
237
257
  resume: resuming,
258
+ ...(attachmentPlan.nativeImagePaths.length > 0
259
+ ? { imagePaths: attachmentPlan.nativeImagePaths }
260
+ : {}),
238
261
  });
239
262
  const activities = [];
240
263
  let sessionId = launchSessionId;
@@ -242,6 +265,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
242
265
  let observedModel = currentModel;
243
266
  let finalText = null;
244
267
  let sentViaCrew = false;
268
+ const externalOutput = new ExternalAnswerDecoder();
245
269
  const readline = createInterface({ input: child.stdout });
246
270
  readline.on("line", (line) => {
247
271
  const event = parseLine(line);
@@ -266,6 +290,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
266
290
  && "type" in event && event.type === "kimi.acp.text_delta";
267
291
  finalText = incremental ? `${finalText ?? ""}${extracted}` : extracted;
268
292
  }
293
+ for (const text of decodeExternalOutputEvent(runtime.name, event, externalOutput)) {
294
+ callbacks.onExternalOutput?.(text);
295
+ }
269
296
  for (const chunk of toConsoleLines(event))
270
297
  callbacks.onConsole?.(chunk);
271
298
  });
@@ -313,11 +340,22 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
313
340
  resumed: resuming,
314
341
  sessionId,
315
342
  errorMessage: errorTail || null,
316
- finalText: input.captureFinal ? finalText : null,
343
+ finalText: input.captureFinal && finalText !== null
344
+ ? stripExternalAnswerMarkers(finalText)
345
+ : null,
317
346
  sentViaCrew,
318
347
  };
319
348
  }
320
349
  finally {
350
+ if (materialized) {
351
+ try {
352
+ await (dependencies.cleanupMaterializedAttachments ?? cleanupAttachments)(materialized.directory);
353
+ }
354
+ catch (error) {
355
+ const detail = error instanceof Error ? error.message : String(error);
356
+ process.stderr.write(`[execution] failed to remove attachments ${materialized.directory}: ${detail}\n`);
357
+ }
358
+ }
321
359
  try {
322
360
  await rm(workspace.systemPromptPath, { force: true });
323
361
  }
@@ -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,8 @@ 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",
24
27
  ];
25
28
  export const EXECUTION_PROTOCOL = Object.freeze({ min: 1, max: 1 });
26
29
  /** 候选 runtime CLI:展示名 → 可执行文件名。 */
@@ -36,7 +39,8 @@ const RUNTIME_BINS = [
36
39
  ];
37
40
  async function isInstalled(bin) {
38
41
  try {
39
- await execFileP(lookupCmd(), [bin]);
42
+ // 用增强 PATH 探测:daemon 继承的 PATH 可能缺 ~/.local/bin 等用户级目录(Claude 原生安装器落点)
43
+ await execFileP(lookupCmd(), [bin], { env: { ...process.env, PATH: augmentedPath() } });
40
44
  return true;
41
45
  }
42
46
  catch {
@@ -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);