@nowcrew/daemon 0.5.11 → 0.5.13

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/dist/runner.js CHANGED
@@ -1,255 +1,108 @@
1
- /**
2
- * Agent 运行编排:换令牌 备 workspace → spawn runtime → 归一化事件流。
3
- */
4
- import { createInterface } from "node:readline";
5
- import { writeFile } from "node:fs/promises";
6
- import { delimiter, join } from "node:path";
1
+ /** Legacy agent orchestration around the business-neutral local executor. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
7
4
  import { mintAgentToken } from "./token.js";
8
- import { prepareWorkspace, rotateAgentSession } from "./workspace.js";
9
- import { buildSystemPrompt, buildWakePrompt, capWorkLogForInject } from "./prompt.js";
10
- import { spawnClaude } from "./runtimes/claude.js";
11
- import { spawnCodex } from "./runtimes/codex.js";
12
- import { applyProviderEnv, providerFingerprint } from "./provider-env.js";
13
- import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
14
- import { normalizeEvent, parseLine, extractFinalText, extractRunMeta } from "./normalize.js";
15
- import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
16
- import { toConsoleLines } from "./console.js";
17
- import { dslog } from "./slog.js";
18
- import { deliverScheduledReport } from "./scheduled-report.js";
19
- // 注入提示词的 MEMORY.md 上限:只喂索引/角色,避免把膨胀的记忆全塞进上下文。
20
- const MEMORY_INJECT_CAP = 6000;
5
+ import { buildSystemPrompt, buildWakePrompt, capMemoryForInject, capWorkLogForInject } from "./prompt.js";
6
+ import { deliverScheduledReport, } from "./scheduled-report.js";
7
+ import { executeLocal } from "./local-executor.js";
8
+ import { ReasoningSchema } from "./execution-protocol.js";
9
+ import { readOriginDecisionFile, resetOriginDecisionFile } from "./origin-decision.js";
10
+ export { awaitExit, exitActivity, sanitizeEnvVars } from "./local-executor.js";
21
11
  const ICON = {
22
12
  init: "🟢", text: "💬", reading: "📖", sending: "📨", checking: "🔎",
23
13
  claiming: "📌", crew: "⚙️", tool: "🛠️", tool_result: "↩️", done: "✅", error: "❌",
24
14
  };
25
- export async function runAgent(config, input, onActivity = defaultPrint,
26
- // 终端透传:每条 stream-json 事件除归一化为状态活动外,再产出 console 行供前端终端窗口渲染。
27
- onConsole = () => { }) {
28
- // 1) 用机器令牌换 per-launch agent 令牌
29
- const cred = await mintAgentToken(config.serverUrl, config.machineToken, input.handle, input.displayName, input.wakeMessageId);
30
- const cfg = cred.config ?? {};
31
- const runtime = cfg.runtime ?? config.runtimeBin;
32
- const currentModel = cfg.model ?? null;
33
- // provider 配置指纹:custom 配置变更后旧会话不可 --resume(见 provider-env.ts)
34
- const providerFp = providerFingerprint(runtime, cfg);
35
- // 2) 准备 workspace(共享 home + 本任务隔离 cwd + per-task work-log)。
36
- // 先 prepare 拿到 memory/workLog,再 build 系统提示词(注入记忆索引)写入。
37
- const ws = await prepareWorkspace({
38
- agentsRoot: config.agentsRoot,
39
- handle: input.handle,
40
- cliPath: config.cliPath,
41
- ...(input.taskKey ? { taskKey: input.taskKey } : {}),
42
- // 仅在首次创建 MEMORY.md 时,用 agent 的 description 种子化 ## Role
43
- ...(cred.config?.description ? { description: cred.config.description } : {}),
44
- });
45
- // session resume:同任务有历史会话且仍在缓存窗口内 → spawn 时 --resume 复用上下文(省 token)。
46
- // 关:CREW_RESUME=off;超 warm 窗口 → 冷启动(避免 cache miss 重写更贵);读取容错(损坏 → 当首轮)。
47
- const supportsNativeResume = runtime === "claude";
48
- const prior = config.resume && supportsNativeResume ? await readSession(ws.runDir) : null;
49
- // 会话身份唯一来源:workspace 的确定性 uuid(ws.agentSessionId)。HEAD 的 warm-window + 开关
50
- // 只决定「是否续用」:既有会话(sessionResume)且仍在缓存窗口内 → --resume;否则冷启动。
51
- const resuming = supportsNativeResume && ws.sessionResume &&
52
- pickResumeId(prior, Date.now(), config.resumeWarmMs, currentModel, config.sessionBudgetTokens, config.sessionMaxTurns, providerFp) != null;
53
- // 预算轮换判定(仅为留痕/提示区分原因):不带预算参数也会续 → 本次冷启动是预算导致的主动轮换
54
- const rotatedForBudget = !resuming && supportsNativeResume && ws.sessionResume &&
55
- pickResumeId(prior, Date.now(), config.resumeWarmMs, currentModel, 0, 0, providerFp) != null;
56
- // soft 预算:仍在续用但上下文已接近 hard 阈值 → 本轮提醒 agent 把现状蒸馏进 work-log(为轮换做准备)
57
- const nearBudget = resuming && isNearBudget(prior, config.sessionSoftTokens);
58
- // 既有会话但本轮不续用 → 轮换出新 uuid 冷启动(避免 --session-id 撞已存在会话)。
59
- const rotated = !!(ws.agentSessionId && ws.sessionResume && !resuming);
60
- const launchSessionId = rotated ? await rotateAgentSession(ws.runDir) : ws.agentSessionId;
61
- // resume 决策是「会话为什么没续上/为什么重开」的直接证据,把判定依据全量留痕
62
- dslog("session.resume_decision", resuming ? "续用上轮会话 (--resume)" : "冷启动新会话", {
63
- run_id: input.runId, agent_handle: input.handle, channel_id: input.channelId,
64
- task_key: input.taskKey, runtime, model: currentModel,
65
- resuming, rotated, session_id: launchSessionId,
66
- prior_session_id: prior?.sessionId ?? null,
67
- prior_age_ms: prior ? Date.now() - prior.lastRunAt : null,
68
- warm_ms: config.resumeWarmMs,
69
- decision_reason: !supportsNativeResume ? "runtime_no_resume"
70
- : !config.resume ? "resume_disabled"
71
- : !ws.sessionResume ? "first_run"
72
- : resuming ? (nearBudget ? "warm_resume_near_budget" : "warm_resume")
73
- : prior?.lastExitOk === false ? "prior_run_crashed"
74
- : rotatedForBudget ? "rotated_for_budget"
75
- : "warm_window_expired_or_model_changed",
76
- prior_context_tokens: prior?.contextTokens ?? null,
77
- prior_last_exit_ok: prior?.lastExitOk ?? null,
78
- budget_tokens: config.sessionBudgetTokens,
15
+ function runtimeName(value) {
16
+ if (value === "claude" || value === "codex" || value === "kimi")
17
+ return value;
18
+ throw new Error(`unsupported runtime: ${value}`);
19
+ }
20
+ export async function runAgent(config, input, onActivity = defaultPrint, onConsole = () => { }) {
21
+ const credential = await mintAgentToken(config.serverUrl, config.machineToken, input.handle, input.displayName, {
22
+ ...(input.wakeMessageId ? { wakeThreadRoot: input.wakeMessageId } : {}),
23
+ ...(input.wakeContextUpToSeq === undefined ? {} : { wakeContextUpToSeq: input.wakeContextUpToSeq }),
24
+ ...(input.runId ? { agentRunId: input.runId } : {}),
79
25
  });
80
- const systemPrompt = buildSystemPrompt({
26
+ const providerConfig = credential.config ?? {};
27
+ const runtime = runtimeName(providerConfig.runtime ?? config.runtimeBin);
28
+ const reasoning = ReasoningSchema.safeParse(providerConfig.reasoning);
29
+ const baseWake = input.wake ?? buildWakePrompt(input.channelId);
30
+ const executionId = input.runId ?? `legacy-${randomUUID()}`;
31
+ const originDecisionFileName = input.wakeOrigin === "wecom"
32
+ ? `.origin-decision-${executionId}.json`
33
+ : null;
34
+ const local = await executeLocal({
35
+ executionId,
81
36
  handle: input.handle,
82
37
  channelId: input.channelId,
83
- agentId: cred.agentId,
84
- homeDir: ws.dir,
85
- productName: config.productName,
86
- ...(input.scheduled ? { scheduledOutputPolicy: input.scheduled.outputPolicy } : {}),
87
- // 只注入 MEMORY.md 的索引/角色部分(截断),避免上下文膨胀;明细让 agent 按需读 notes/。
88
- memory: ws.memory.length > MEMORY_INJECT_CAP
89
- ? ws.memory.slice(0, MEMORY_INJECT_CAP) + "\n…(MEMORY.md 过长已截断,详情用 Read 读 $CREW_HOME/MEMORY.md 或 notes/)"
90
- : ws.memory,
91
- // resume 时进度已在对话历史里,省去重喂 work-log(resume token 的主要来源);首轮才注入。
92
- // 注入截断(防随文件膨胀):全文仍在盘上,超限时保头尾、提示按需 Read。
93
- ...(resuming ? {} : { workLog: capWorkLogForInject(ws.workLog) }),
94
- });
95
- await writeFile(ws.systemPromptPath, systemPrompt, "utf8");
96
- // 3) spawn runtime,注入 PATH(crew wrapper)、凭证 env、以及 agent 运行时配置
97
- // provider=custom BYOC(ANTHROPIC_BASE_URL/API_KEY);model → --model;
98
- // reasoning → 原生思考强度(claude --effort / codex -c model_reasoning_effort= /
99
- // kimi KIMI_MODEL_THINKING_EFFORT env;档位白名单在各 runtime 适配器里,白名单外不传)。
100
- // resume 时提示 agent 上下文已在,无需从头重读频道(配合不注入 work-log,进一步省 token)。
101
- const baseWake = input.wake ?? buildWakePrompt(input.channelId);
102
- const distillHint = nearBudget
103
- ? `\n(注意:本会话上下文已接近预算,稍后将轮换重启。本轮结束前,把当前状态——阶段/关键结论/下一步/卡点/关键文件路径——完整更新到 $CREW_TASK_LOG,下轮将以它为基础继续。)`
104
- : "";
105
- const wakePrompt = resuming
106
- ? `(继续之前的会话:频道历史与你的进度已在上下文里,不必从头重读;要新消息用 \`crew message read --channel ${input.channelId}\` 增量拉即可。)${distillHint}\n\n${baseWake}`
107
- : rotatedForBudget
108
- ? `(上下文已轮换:之前的会话过大已重启。你的 work-log 已注入系统提示词末尾,以它为基础继续;频道/线程历史用 crew 命令按需拉取,不必全量重读。)\n\n${baseWake}`
109
- : baseWake;
110
- const baseEnv = {
111
- ...process.env,
112
- // 用户自定义 env(表单 ENVIRONMENT VARIABLES):先合入,可覆盖继承的 shell 环境;
113
- // 但 PATH/CREW_*/XDG_* 及下方由表单生成的值(ANTHROPIC_*/思考强度)在其后合入,始终以系统为准。
114
- ...sanitizeEnvVars(cfg.envVars),
115
- PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
116
- CREW_SERVER_URL: config.serverUrl,
117
- CREW_TOKEN: cred.token,
118
- CREW_CHANNEL: input.channelId,
119
- // 共享持久记忆 home(MEMORY.md/notes 在此;cwd 是本任务隔离目录)+ 本任务 work-log 路径
120
- CREW_HOME: ws.dir,
121
- CREW_TASK_LOG: ws.workLogPath,
122
- // 唤醒锚点消息 id:有则 `crew task create` 把任务锚定到这条触发消息(讨论与任务锚点统一),
123
- // 而非另发一条标题消息当锚点(那会让点开 task thread 永远为空)
124
- ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
125
- // per-agent 凭证隔离:XDG 指向本 agent 独立目录(gh/gcloud CLI 的 token 不互相串)。
126
- // 不覆盖 HOME(否则会破坏 claude 自身的 ~/.claude 鉴权);常用 CLI 也单独点名隔离。
127
- XDG_CONFIG_HOME: join(ws.homeDir, ".config"),
128
- XDG_DATA_HOME: join(ws.homeDir, ".local", "share"),
129
- XDG_CACHE_HOME: join(ws.homeDir, ".cache"),
130
- GH_CONFIG_DIR: join(ws.homeDir, ".config", "gh"),
131
- CLOUDSDK_CONFIG: join(ws.homeDir, ".config", "gcloud"),
132
- // provider custom(BYOC)端点/密钥注入移至下方 applyProviderEnv(含全局残留清理与配置目录隔离)
133
- // reasoning → kimi 无对应 CLI 参数,走 KIMI_MODEL_THINKING_EFFORT env(kimi-code 0.23.0,
134
- // 值域 low/medium/high/xhigh/max);claude/codex 改走各自 spawn 原生参数(见下),不再注 env。
135
- ...(runtime === "kimi" && cfg.reasoning && KIMI_EFFORT_LEVELS.includes(cfg.reasoning)
136
- ? { KIMI_MODEL_THINKING_EFFORT: cfg.reasoning }
137
- : {}),
138
- // fast 模式 透传给 runtime(best-effort,供 wrapper/runtime 读取)
139
- ...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
140
- ...(input.scheduled ? { CREW_SCHEDULE_OUTPUT_POLICY: input.scheduled.outputPolicy } : {}),
141
- };
142
- // provider custom = BYOC:剔除全局 ANTHROPIC_*/Bedrock 残留 + 按鉴权方式注入端点与密钥,
143
- // 与机器全局 Claude 登录态/配置互不干扰(细节见 provider-env.ts)。
144
- const childEnv = applyProviderEnv(baseEnv, runtime, cfg, ws.homeDir);
145
- const child = runtime === "claude"
146
- ? spawnClaude({
147
- bin: runtime,
148
- cwd: ws.runDir, // 本任务隔离工作目录(并行运行互不干扰)
149
- systemPromptPath: ws.systemPromptPath,
150
- wakePrompt,
151
- dangerous: config.dangerous,
152
- ...(currentModel ? { model: currentModel } : {}),
153
- ...(cfg.reasoning ? { reasoning: cfg.reasoning } : {}),
154
- // 一线程一会话(唯一会话机制):首轮/冷启动 --session-id 固定 uuid,warm 续轮 --resume 续上。
155
- ...(launchSessionId ? { sessionId: launchSessionId, resume: resuming } : {}),
156
- env: childEnv,
157
- })
158
- : runtime === "codex"
159
- ? spawnCodex({
160
- bin: runtime,
161
- cwd: ws.runDir,
162
- wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
163
- dangerous: config.dangerous,
164
- ...(currentModel ? { model: currentModel } : {}),
165
- ...(cfg.reasoning ? { reasoning: cfg.reasoning } : {}),
166
- env: childEnv,
167
- })
168
- : runtime === "kimi"
169
- ? spawnKimi({
170
- bin: runtime,
171
- cwd: ws.runDir,
172
- // kimi 与 codex 一样没有 system prompt 参数,拼在 wake prompt 前;
173
- // -p 模式固定 auto 权限,dangerous 无对应 flag(见 runtimes/kimi.ts)。
174
- wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
175
- ...(currentModel ? { model: currentModel } : {}),
176
- env: childEnv,
177
- })
178
- : (() => {
179
- throw new Error(`unsupported runtime: ${runtime}`);
180
- })();
181
- // 记下 OS 进程号:排查「进程被谁杀的/是否 OOM」时可与系统日志对齐
182
- dslog("run.spawned", `${runtime} 进程已拉起 (pid ${child.pid})`, {
183
- run_id: input.runId, agent_handle: input.handle, channel_id: input.channelId,
184
- task_key: input.taskKey, runtime, model: currentModel,
185
- os_pid: child.pid ?? null, session_id: launchSessionId, resume: resuming,
186
- });
187
- // 4) 逐行解析 stdout → 归一化 → 回调;顺带抓 session_id(记 lastRunAt 供 warm-window)+ token usage(度量)
188
- const activities = [];
189
- // 身份是本轮下发的 launchSessionId;仍兜底采 claude 自报的 session_id(理应一致)。
190
- let sessionId = launchSessionId;
191
- let usage;
192
- let observedModel = currentModel;
193
- let finalText = null;
194
- let sentViaCrew = false;
195
- const rl = createInterface({ input: child.stdout });
196
- rl.on("line", (line) => {
197
- const evt = parseLine(line);
198
- if (!evt)
199
- return;
200
- const meta = extractRunMeta(evt);
201
- if (meta.sessionId)
202
- sessionId = meta.sessionId;
203
- if (meta.usage)
204
- usage = meta.usage;
205
- if (meta.model)
206
- observedModel = meta.model;
207
- for (const a of normalizeEvent(evt)) {
208
- if (a.kind === "sending")
209
- sentViaCrew = true;
210
- activities.push(a);
211
- onActivity(a);
212
- }
213
- const extracted = extractFinalText(evt);
214
- if (extracted)
215
- finalText = extracted;
216
- // 同一事件再透传为终端 console 行(独立于状态活动,内容不压缩)。
217
- for (const c of toConsoleLines(evt))
218
- onConsole(c);
219
- });
220
- // stderr 除透传本地终端外,再留一段尾部:runtime 启动即崩(如 codex 拒跑)时,
221
- // 这是唯一的错误线索,要随 error 活动上送,否则失败对 server/web 完全不可见。
222
- let stderrTail = "";
223
- child.stderr.on("data", (d) => {
224
- process.stderr.write(d);
225
- stderrTail = (stderrTail + String(d)).slice(-STDERR_TAIL_CAP);
226
- });
227
- const { exitCode, spawnError, terminationSignal } = await awaitExit(child);
228
- // spawn 本身失败(如 PATH 里没有 runtime 二进制)没有 stderr,把错误并入尾部供上报。
229
- const errorTail = [
230
- stderrTail.trim(),
231
- spawnError,
232
- terminationSignal ? `terminated by ${terminationSignal}` : undefined,
233
- ].filter(Boolean).join(" ").trim();
234
- const finish = exitActivity(runtime, exitCode, errorTail);
235
- if (finish) {
236
- activities.push(finish);
237
- onActivity(finish);
238
- if (finish.kind === "error")
239
- onConsole({ stream: "error", text: `✖ ${finish.detail ?? finish.label}` });
240
- }
241
- if (!input.scheduled && (runtime === "codex" || runtime === "kimi") && exitCode === 0 && !sentViaCrew && finalText) {
242
- // force:兜底回帖锚定本轮触发消息的线程,语义上必须送达;不 force 时 agent(-p 单发不跑
243
- // crew read)游标落后,回帖会被 freshness hold 成 draft(202)而永远不可见。
244
- const sent = await sendAgentMessage(config.serverUrl, cred.token, input.channelId, {
245
- content: finalText,
38
+ ...(input.taskKey === undefined ? {} : { taskKey: input.taskKey }),
39
+ ...(input.wakeMessageId === undefined ? {} : { wakeMessageId: input.wakeMessageId }),
40
+ systemPrompt: ({ workspace, resuming }) => buildSystemPrompt({
41
+ handle: input.handle,
42
+ channelId: input.channelId,
43
+ agentId: credential.agentId,
44
+ homeDir: workspace.dir,
45
+ productName: config.productName,
46
+ ...(input.scheduled ? { scheduledOutputPolicy: input.scheduled.outputPolicy } : {}),
47
+ ...(input.wakeOrigin ? { wakeOrigin: input.wakeOrigin } : {}),
48
+ memory: capMemoryForInject(workspace.memory),
49
+ ...(resuming ? {} : { workLog: capWorkLogForInject(workspace.workLog) }),
50
+ }),
51
+ wakePrompt: ({ resuming, rotatedForBudget, nearBudget }) => {
52
+ const distillHint = nearBudget
53
+ ? "\n(注意:本会话上下文已接近预算,稍后将轮换重启。本轮结束前,把当前状态——阶段/关键结论/下一步/卡点/关键文件路径——完整更新到 $CREW_TASK_LOG,下轮将以它为基础继续。)"
54
+ : "";
55
+ if (resuming) {
56
+ return `(继续之前的会话:频道历史与你的进度已在上下文里,不必从头重读;要新消息用 \`crew message read --channel ${input.channelId}\` 增量拉即可。)${distillHint}\n\n${baseWake}`;
57
+ }
58
+ return rotatedForBudget
59
+ ? `(上下文已轮换:之前的会话过大已重启。你的 work-log 已注入系统提示词末尾,以它为基础继续;频道/线程历史用 crew 命令按需拉取,不必全量重读。)\n\n${baseWake}`
60
+ : baseWake;
61
+ },
62
+ runtime: {
63
+ name: runtime,
64
+ ...(providerConfig.model ? { model: providerConfig.model } : {}),
65
+ ...(reasoning.success ? { reasoning: reasoning.data } : {}),
66
+ },
67
+ effectivePermission: config.dangerous ? "full_access" : "workspace_write",
68
+ captureFinal: true,
69
+ launch: {
70
+ serverUrl: config.serverUrl,
71
+ token: credential.token,
72
+ agentId: credential.agentId,
73
+ agentsRoot: config.agentsRoot,
74
+ cliPath: config.cliPath,
75
+ providerConfig,
76
+ ...(providerConfig.description ? { description: providerConfig.description } : {}),
77
+ systemEnv: {
78
+ ...(providerConfig.fastMode ? { CREW_FAST_MODE: "1" } : {}),
79
+ ...(input.scheduled ? { CREW_SCHEDULE_OUTPUT_POLICY: input.scheduled.outputPolicy } : {}),
80
+ ...(originDecisionFileName ? {
81
+ CREW_WAKE_ORIGIN: "wecom",
82
+ CREW_ORIGIN_DECISION_FILE: originDecisionFileName,
83
+ } : {}),
84
+ },
85
+ },
86
+ session: {
87
+ enabled: config.resume,
88
+ warmMs: config.resumeWarmMs,
89
+ budgetTokens: config.sessionBudgetTokens,
90
+ softTokens: config.sessionSoftTokens,
91
+ maxTurns: config.sessionMaxTurns,
92
+ },
93
+ }, { onActivity, onConsole });
94
+ const activities = [...local.activities];
95
+ if (!input.scheduled && (runtime === "codex" || runtime === "kimi")
96
+ && local.exitCode === 0 && !local.sentViaCrew && local.finalText) {
97
+ const sent = await sendAgentMessage(config.serverUrl, credential.token, input.channelId, {
98
+ content: local.finalText,
246
99
  force: true,
247
100
  ...(input.wakeMessageId ? { thread: input.wakeMessageId } : {}),
248
101
  });
249
102
  if (sent.delivered) {
250
- const a = { kind: "sending", label: "发消息", detail: `${runtime} final answer fallback` };
251
- activities.push(a);
252
- onActivity(a);
103
+ const activity = { kind: "sending", label: "发消息", detail: `${runtime} final answer fallback` };
104
+ activities.push(activity);
105
+ onActivity(activity);
253
106
  }
254
107
  else if (sent.status === 202) {
255
108
  process.stderr.write(`${runtime} fallback reply was held as draft (HTTP 202) — not visible in channel\n`);
@@ -258,134 +111,89 @@ onConsole = () => { }) {
258
111
  process.stderr.write(`${runtime} fallback send failed (HTTP ${sent.status})\n`);
259
112
  }
260
113
  }
261
- // 5) 落盘 session(下次同任务可 --resume),并打印本轮 token 用量(度量 resume 真省与否)
262
- if (config.resume && supportsNativeResume && sessionId) {
263
- // turns 只在续用时累加,轮换/新会话从 1 重计(否则 maxTurns 兜底会让之后每轮都轮换)。
264
- // contextTokens = 本轮实测上下文体量;本轮无 usage 时续用轮保留上轮值(会话仍在),新会话清零。
265
- const contextTokens = usage
266
- ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
267
- : (resuming ? prior?.contextTokens : undefined);
268
- await writeSession(ws.runDir, {
269
- sessionId,
270
- lastRunAt: Date.now(),
271
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
272
- model: currentModel,
273
- providerFingerprint: providerFp,
274
- // 非零退出(崩溃/超窗/被杀)标记本会话不健康 → 下轮 pickResumeId 强制冷启动,不再续这个坏会话。
275
- lastExitOk: exitCode === 0,
276
- ...(contextTokens != null ? { contextTokens } : {}),
277
- });
278
- }
279
- if (usage) {
280
- const u = usage;
281
- process.stdout.write(`📊 tokens: in=${u.inputTokens} out=${u.outputTokens} cache_read=${u.cacheReadTokens} cache_create=${u.cacheCreationTokens}` +
282
- `${u.costUsd != null ? ` cost=$${u.costUsd.toFixed(4)}` : ""} ${resuming ? "(resumed)" : rotatedForBudget ? "(rotated: budget)" : "(fresh)"}\n`);
114
+ if (local.usage) {
115
+ const usage = local.usage;
116
+ process.stdout.write(`📊 tokens: in=${usage.inputTokens} out=${usage.outputTokens} cache_read=${usage.cacheReadTokens} cache_create=${usage.cacheCreationTokens}`
117
+ + `${usage.costUsd != null ? ` cost=$${usage.costUsd.toFixed(4)}` : ""} ${local.resumed ? "(resumed)" : "(fresh)"}\n`);
283
118
  }
284
- // 所有可能抛错的本地收尾完成后再投递,避免“报告已发出但随后落盘失败”被 serve catch
285
- // 当成另一轮失败再次通知。deliverScheduledReport 自身吞掉网络异常并返回可持久化 outcome。
286
119
  const report = input.scheduled
287
120
  ? await deliverScheduledReport({
288
121
  policy: input.scheduled.outputPolicy,
289
122
  title: input.scheduled.title,
290
- exitCode,
291
- finalText,
292
- errorMessage: errorTail || null,
293
- send: (content) => sendAgentMessage(config.serverUrl, cred.token, input.channelId, {
123
+ exitCode: local.exitCode,
124
+ finalText: local.finalText,
125
+ errorMessage: local.errorMessage,
126
+ send: (content) => sendAgentMessage(config.serverUrl, credential.token, input.channelId, {
294
127
  content,
295
128
  force: true,
296
129
  }),
297
130
  })
298
131
  : undefined;
132
+ const originDecisionPath = originDecisionFileName
133
+ ? join(local.workspaceRunDir, originDecisionFileName)
134
+ : null;
135
+ const originDecision = originDecisionPath ? await readOriginDecisionFile(originDecisionPath) : null;
136
+ if (originDecisionPath)
137
+ await resetOriginDecisionFile(originDecisionPath);
299
138
  return {
300
- exitCode,
139
+ exitCode: local.exitCode,
301
140
  activities,
302
- model: observedModel,
141
+ model: local.model,
303
142
  runtime,
304
- resumed: resuming,
305
- sessionId,
306
- errorMessage: errorTail || null,
307
- ...(usage ? { usage } : {}),
143
+ resumed: local.resumed,
144
+ sessionId: local.sessionId,
145
+ errorMessage: local.errorMessage,
146
+ ...(local.usage ? { usage: local.usage } : {}),
308
147
  ...(report ? { report } : {}),
148
+ ...(input.wakeOrigin === "wecom" ? {
149
+ originDecision: originDecision?.decision ?? "missing",
150
+ ...(originDecision?.decision === "silent" && originDecision.reason
151
+ ? { originDecisionReason: originDecision.reason }
152
+ : {}),
153
+ } : {}),
309
154
  };
310
155
  }
311
- /** runAgent 在 workspace/spawn 等前置阶段抛错时的可见失败兜底。 */
312
156
  export async function reportScheduledStartFailure(config, input) {
313
- const cred = await mintAgentToken(config.serverUrl, config.machineToken, input.handle);
157
+ const credential = await mintAgentToken(config.serverUrl, config.machineToken, input.handle);
314
158
  return deliverScheduledReport({
315
159
  policy: input.scheduled.outputPolicy,
316
160
  title: input.scheduled.title,
317
161
  exitCode: -1,
318
162
  finalText: null,
319
163
  errorMessage: input.errorMessage,
320
- send: (content) => sendAgentMessage(config.serverUrl, cred.token, input.channelId, {
164
+ send: (content) => sendAgentMessage(config.serverUrl, credential.token, input.channelId, {
321
165
  content,
322
166
  force: true,
323
167
  }),
324
168
  });
325
169
  }
326
- /** error 活动上送的 stderr 尾部上限。 */
327
- const STDERR_TAIL_CAP = 2000;
328
- /**
329
- * 等待子进程结束。必须监听 error:spawn 失败( PATH 里没有该 runtime 的二进制)
330
- * Node 只发 error 不发 close——不监听会以未处理异常炸掉整个 daemon 进程,
331
- * close 永不触发导致本轮永久挂起。取先到的事件为准。
332
- */
333
- export function awaitExit(child) {
334
- return new Promise((resolve) => {
335
- let settled = false;
336
- const settle = (r) => {
337
- if (settled)
338
- return;
339
- settled = true;
340
- resolve(r);
341
- };
342
- child.on("error", (e) => settle({ exitCode: -1, spawnError: e.message }));
343
- child.on("close", (code, signal) => settle(code === null
344
- ? { exitCode: 128, terminationSignal: signal ?? "unknown" }
345
- : { exitCode: code }));
346
- });
347
- }
348
- /**
349
- * 进程退出 → 收尾活动。codex/kimi 非零退出必须显式报 error(它们失败时往往一条事件都没吐,
350
- * 不报就会被 serve 的「本轮结束」伪装成成功);exitCode -1 是 awaitExit 的 spawn 失败哨兵,
351
- * 任何 runtime 都报(spawn 失败连事件流都没有);kimi 正常退出补 done(其 stream 无轮次结束事件);
352
- * codex 正常退出与 claude 均返回 null(终态由 turn.completed / result 事件负责)。
353
- */
354
- export function exitActivity(runtime, exitCode, stderrTail) {
355
- if ((runtime === "codex" || runtime === "kimi" || exitCode === -1) && exitCode !== 0) {
356
- return {
357
- kind: "error",
358
- label: "运行出错",
359
- detail: `${runtime} exited with code ${exitCode}${stderrTail ? `: ${stderrTail}` : ""}`,
360
- };
361
- }
362
- if (runtime === "kimi" && exitCode === 0)
363
- return { kind: "done", label: "本轮结束" };
364
- return null;
365
- }
366
- function defaultPrint(a) {
367
- const icon = ICON[a.kind] ?? "·";
368
- const detail = a.detail ? ` ${a.detail.replace(/\s+/g, " ").slice(0, 120)}` : "";
369
- process.stdout.write(`${icon} ${a.label}${detail}\n`);
170
+ export function mergeRunAgentResults(results) {
171
+ const final = results[results.length - 1];
172
+ if (!final)
173
+ throw new Error("cannot merge empty run results");
174
+ const usages = results.flatMap((result) => result.usage ? [result.usage] : []);
175
+ const usage = usages.length > 0 ? {
176
+ inputTokens: usages.reduce((sum, item) => sum + item.inputTokens, 0),
177
+ outputTokens: usages.reduce((sum, item) => sum + item.outputTokens, 0),
178
+ cacheReadTokens: usages.reduce((sum, item) => sum + item.cacheReadTokens, 0),
179
+ cacheCreationTokens: usages.reduce((sum, item) => sum + item.cacheCreationTokens, 0),
180
+ ...(usages.some((item) => item.costUsd != null)
181
+ ? { costUsd: usages.reduce((sum, item) => sum + (item.costUsd ?? 0), 0) }
182
+ : {}),
183
+ } : undefined;
184
+ return {
185
+ ...final,
186
+ activities: results.flatMap((result) => result.activities),
187
+ ...(usage ? { usage } : {}),
188
+ };
370
189
  }
371
- // daemon 侧兜底(server 已校验,这里防旧数据/绕过):只接受合法 key 的 string 值,CREW_ 前缀保留给系统。
372
- const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
373
- export function sanitizeEnvVars(raw) {
374
- if (!raw)
375
- return {};
376
- const out = {};
377
- for (const [k, v] of Object.entries(raw)) {
378
- if (typeof v !== "string")
379
- continue;
380
- if (!ENV_KEY_RE.test(k) || k.toUpperCase().startsWith("CREW_"))
381
- continue;
382
- out[k] = v;
383
- }
384
- return out;
190
+ function defaultPrint(activity) {
191
+ const icon = ICON[activity.kind] ?? "·";
192
+ const detail = activity.detail ? ` ${activity.detail.replace(/\s+/g, " ").slice(0, 120)}` : "";
193
+ process.stdout.write(`${icon} ${activity.label}${detail}\n`);
385
194
  }
386
- /** exported for tests */
387
195
  export async function sendAgentMessage(serverUrl, token, channelId, body) {
388
- const res = await fetch(`${serverUrl}/agent/channels/${encodeURIComponent(channelId)}/messages`, {
196
+ const response = await fetch(`${serverUrl}/agent/channels/${encodeURIComponent(channelId)}/messages`, {
389
197
  method: "POST",
390
198
  headers: {
391
199
  authorization: `Bearer ${token}`,
@@ -393,7 +201,5 @@ export async function sendAgentMessage(serverUrl, token, channelId, body) {
393
201
  },
394
202
  body: JSON.stringify(body),
395
203
  });
396
- // 只有 201(sent)算送达;202 表示被 freshness hold draft——消息没有出现在频道里,
397
- // 不能当成功(把 202 当成功正是 kimi/codex fallback 回帖丢失的根因)。
398
- return { delivered: res.status === 201, status: res.status };
204
+ return { delivered: response.status === 201, status: response.status };
399
205
  }
@@ -23,8 +23,16 @@ export function buildClaudeArgs(input) {
23
23
  if (input.sessionId) {
24
24
  args.push(input.resume ? "--resume" : "--session-id", input.sessionId);
25
25
  }
26
- if (input.dangerous)
26
+ if (input.effectivePermission === undefined) {
27
+ if (input.dangerous)
28
+ args.push("--dangerously-skip-permissions");
29
+ }
30
+ else if (input.effectivePermission === "full_access") {
27
31
  args.push("--dangerously-skip-permissions");
32
+ }
33
+ else {
34
+ args.push("--permission-mode", input.effectivePermission === "sandboxed" ? "plan" : "acceptEdits");
35
+ }
28
36
  args.push(input.wakePrompt);
29
37
  return args;
30
38
  }
@@ -16,16 +16,32 @@ export function buildCodexArgs(input) {
16
16
  if (input.reasoning && CODEX_EFFORT_LEVELS.includes(input.reasoning)) {
17
17
  args.push("-c", `model_reasoning_effort=${input.reasoning}`);
18
18
  }
19
- if (input.dangerous)
19
+ if (input.effectivePermission === "sandboxed")
20
+ args.push("--sandbox", "read-only");
21
+ else if (input.effectivePermission === "workspace_write")
22
+ args.push("--sandbox", "workspace-write");
23
+ else if (input.effectivePermission === "full_access" || (input.effectivePermission === undefined && input.dangerous)) {
20
24
  args.push("--dangerously-bypass-approvals-and-sandbox");
21
- args.push(input.wakePrompt);
25
+ }
26
+ // `-` instructs codex exec to read the prompt from stdin. Keeping the complete prompt out of argv
27
+ // avoids Windows' command-line length limit when a thread carries a large wake context.
28
+ args.push("-");
22
29
  return args;
23
30
  }
24
31
  export function spawnCodex(input) {
25
- // stdio 固定 ignore/pipe/pipe,stdout/stderr 必为 Readable;cross-spawn 类型不带该细化,断言之
26
- return spawn(input.bin, buildCodexArgs(input), {
32
+ // stdio 固定 pipe/pipe/pipe;cross-spawn 类型不带该细化,断言之。
33
+ const child = spawn(input.bin, buildCodexArgs(input), {
27
34
  cwd: input.cwd,
28
35
  env: input.env,
29
- stdio: ["ignore", "pipe", "pipe"],
36
+ stdio: ["pipe", "pipe", "pipe"],
30
37
  });
38
+ // Decode at the pipe boundary so split multi-byte characters are buffered correctly before
39
+ // readline, stderr forwarding, activity reporting, and websocket JSON serialization consume them.
40
+ child.stdout.setEncoding("utf8");
41
+ child.stderr.setEncoding("utf8");
42
+ // Codex may exit before consuming a large prompt (for example on config/auth failure). In that
43
+ // case the pipe can emit EPIPE; the child exit code and stderr remain the authoritative failure.
44
+ child.stdin.on("error", () => undefined);
45
+ child.stdin.end(input.wakePrompt);
46
+ return child;
31
47
  }
@@ -14,6 +14,9 @@ import spawn from "cross-spawn";
14
14
  // 由 runner 经 KIMI_MODEL_THINKING_EFFORT env 注入;白名单外的值不注。
15
15
  export const KIMI_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
16
16
  export function buildKimiArgs(input) {
17
+ if (input.effectivePermission !== undefined && input.effectivePermission !== "full_access") {
18
+ throw new Error(`Kimi prompt mode cannot enforce ${input.effectivePermission} permission`);
19
+ }
17
20
  const args = ["--output-format", "stream-json"];
18
21
  if (input.model)
19
22
  args.push("--model", input.model);