@jesonliu/lark-claudecode-bridge 1.3.7 → 1.4.0
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 +17 -0
- package/assets/web/js/main.js +2 -1
- package/assets/web/js/pages/memory.js +561 -0
- package/config.example.yaml +34 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +133 -1
- package/dist/config.js.map +1 -1
- package/dist/executor/claude-executor.d.ts +26 -3
- package/dist/executor/claude-executor.js +718 -293
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/notify-server.d.ts +10 -0
- package/dist/executor/notify-server.js +11 -0
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/permission-gate.d.ts +8 -1
- package/dist/executor/permission-gate.js +7 -3
- package/dist/executor/permission-gate.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +15 -3
- package/dist/gateway/card-builder.js +21 -10
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +13 -0
- package/dist/gateway/feishu-gateway.js +34 -2
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +4 -2
- package/dist/gateway/progress-card.js +52 -25
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +225 -32
- package/dist/index.js.map +1 -1
- package/dist/memory-installer.d.ts +174 -0
- package/dist/memory-installer.js +1035 -0
- package/dist/memory-installer.js.map +1 -0
- package/dist/memory.d.ts +88 -0
- package/dist/memory.js +195 -0
- package/dist/memory.js.map +1 -0
- package/dist/security/paths.d.ts +3 -1
- package/dist/security/paths.js +28 -4
- package/dist/security/paths.js.map +1 -1
- package/dist/session/session-store.d.ts +15 -0
- package/dist/session/session-store.js +16 -0
- package/dist/session/session-store.js.map +1 -1
- package/dist/types.d.ts +56 -1
- package/dist/web/config-api.js +32 -0
- package/dist/web/config-api.js.map +1 -1
- package/dist/web/memory-api.d.ts +25 -0
- package/dist/web/memory-api.js +197 -0
- package/dist/web/memory-api.js.map +1 -0
- package/dist/web/server.js +73 -2
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
// Claude 执行器:封装 Agent SDK 的 query()
|
|
1
|
+
// Claude 执行器:封装 Agent SDK 的 query(),把流消息转成进度事件并收集产出。
|
|
2
2
|
// 注意:不覆盖/清理任何 ANTHROPIC_* 环境变量,凭证与代理配置透传 process.env
|
|
3
|
+
//
|
|
4
|
+
// 生命周期方案 A2——常驻进程池:
|
|
5
|
+
// - 进程按 affinityKey(appId:chatId:userId)亲和:同通道消息继续写同一 CLI 子进程的
|
|
6
|
+
// stdin(streaming input 天然多轮),~30s 冷启动税从「每消息一次」摊薄为「每会话一次」;
|
|
7
|
+
// - 复用三前提:进程活着 + 配置指纹一致(人设/模型/plan/env/MCP/插件/档案 settings/cwd,
|
|
8
|
+
// 任一变化即回收重建——保住「每任务现读热生效」卖点)+ 会话指针一致(/new /resume /ws use
|
|
9
|
+
// 换了续接目标,旧进程上下文不再适用);
|
|
10
|
+
// - 每轮软收尾:现有 finalize(发卡/归档/拉 MCP 状态)在每轮 result 后照常执行,但
|
|
11
|
+
// 不退出消息循环、不杀进程——「收尾」与「退出」分离是本次重构的核心动作;
|
|
12
|
+
// - 崩溃自愈:进程死亡(流结束/抛错)自动出池,下条消息冷启重建 = 退化为旧行为,不会更糟;
|
|
13
|
+
// - 空闲回收:通道空闲超阈值杀进程释放内存(会话无损——指针与 transcript 均落盘,
|
|
14
|
+
// 追问时冷启 + resume 无缝续上);池上限按 app 统计,LRU 淘汰最久未用的空闲进程。
|
|
15
|
+
// - ephemeral(无 affinityKey 或 resident:false)= spawn 一轮即回收的常驻进程,与旧行为等价。
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
3
17
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
4
18
|
import { OutputCollector } from './output-collector.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return controller;
|
|
11
|
-
}
|
|
12
|
-
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
13
|
-
return controller;
|
|
14
|
-
}
|
|
19
|
+
/** 常驻进程默认空闲回收阈值:1 小时(方案 §3.2 建议 1h 起步做成配置) */
|
|
20
|
+
const DEFAULT_RESIDENT_IDLE_MS = 60 * 60 * 1000;
|
|
21
|
+
/** 等待后台任务期间的静默超时:超时仍无任何事件(CLI 异常/卡死)按已有信息收尾,防任务永久挂起。
|
|
22
|
+
* 正常路径无须走到——后台任务完成会唤醒主循环产生新事件,最终由 result/idle 信号收尾 */
|
|
23
|
+
const BG_WAIT_SILENCE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
15
24
|
function summarizeToolInput(input) {
|
|
16
25
|
if (typeof input.command === 'string')
|
|
17
26
|
return input.command;
|
|
@@ -34,315 +43,731 @@ function firstErrorLine(content) {
|
|
|
34
43
|
const line = text.split('\n').map((l) => l.trim()).find((l) => l.length > 0);
|
|
35
44
|
return line ? line.slice(0, 80) : undefined;
|
|
36
45
|
}
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
/** MCP server 配置的可序列化投影(指纹用):只取影响子进程行为的配置字段。
|
|
47
|
+
* sdk 实例型 server(进程内 lcb-notify)不可序列化,按名字记 marker——wiring 侧
|
|
48
|
+
* 每任务新建实例但 delegate 同槽(见 notify-server.ts createNotifySenderSlot),
|
|
49
|
+
* 名字稳定即语义稳定。env/headers 只参与哈希不落日志,无泄漏面 */
|
|
50
|
+
function serializeMcpServers(servers) {
|
|
51
|
+
if (!servers)
|
|
52
|
+
return null;
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [name, raw] of Object.entries(servers)) {
|
|
55
|
+
const c = raw;
|
|
56
|
+
out[name] = c.type === 'sdk'
|
|
57
|
+
? { type: 'sdk', name: typeof c.name === 'string' ? c.name : name }
|
|
58
|
+
: {
|
|
59
|
+
type: c.type,
|
|
60
|
+
command: c.command,
|
|
61
|
+
args: c.args,
|
|
62
|
+
url: c.url,
|
|
63
|
+
env: c.env,
|
|
64
|
+
headers: c.headers ? Object.keys(c.headers) : undefined,
|
|
65
|
+
};
|
|
51
66
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
canUseTool: async (toolName, input, ctx) => {
|
|
76
|
-
const decision = await opts.canUseTool(toolName, input, { suggestions: ctx?.suggestions });
|
|
77
|
-
// 计划批准即授权写操作:对齐 Claude Code CLI「plan 批准 → accept edits on」语义,
|
|
78
|
-
// 后续 Write/Edit 不再逐次弹确认卡(SDK Query.setPermissionMode,官方 permissions 文档姿势)。
|
|
79
|
-
// 分身(planApproveEscalates=false)不升级:acceptEdits 会绕过 canUseTool,
|
|
80
|
-
// 宿主安全闸(R1 红区/R2 写边界)随之失效——安全优先于确认摩擦
|
|
81
|
-
if (decision.behavior === 'allow' && toolName === 'ExitPlanMode'
|
|
82
|
-
&& opts.planApproveEscalates !== false) {
|
|
83
|
-
await q.setPermissionMode('acceptEdits').catch((e) => {
|
|
84
|
-
console.warn('[executor] setPermissionMode(acceptEdits) 失败(继续按批准前模式执行):', e instanceof Error ? e.message : e);
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
return decision;
|
|
88
|
-
},
|
|
89
|
-
}
|
|
90
|
-
: {}),
|
|
91
|
-
...(opts.signal ? { abortController: bridgeSignal(opts.signal) } : {}),
|
|
92
|
-
...(opts.env ? { env: opts.env } : {}),
|
|
93
|
-
...(opts.model ? { model: opts.model } : {}),
|
|
94
|
-
...(opts.mcpServers ? { mcpServers: opts.mcpServers } : {}),
|
|
95
|
-
...(opts.plugins ? { plugins: opts.plugins.map((p) => ({ type: 'local', path: p.path })) } : {}),
|
|
96
|
-
// extraArgs 合并口:调用方传入(--settings 档案认证)+ 人格补充(--append-system-prompt,
|
|
97
|
-
// CLI 2.1.x 只认命令行参数,SDK 同名 option 被静默丢弃——见 RunTaskOptions.appendSystemPrompt 注释)
|
|
98
|
-
...((opts.extraArgs && Object.keys(opts.extraArgs).length) || opts.appendSystemPrompt
|
|
99
|
-
? {
|
|
100
|
-
extraArgs: {
|
|
101
|
-
...(opts.extraArgs ?? {}),
|
|
102
|
-
...(opts.appendSystemPrompt ? { 'append-system-prompt': opts.appendSystemPrompt } : {}),
|
|
103
|
-
},
|
|
104
|
-
}
|
|
105
|
-
: {}),
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
let finalText = '';
|
|
109
|
-
let sessionId = opts.resumeSessionId ?? '';
|
|
110
|
-
let turns = 0;
|
|
111
|
-
// 最后一次 result 的主循环用量:input+cache 三项之和 ≈ 当前上下文规模(超长提醒判定口径)。
|
|
112
|
-
// streaming 模式下每个 result 携带最近 turn 的用量,取最新值即可(不累加)
|
|
113
|
-
let usage;
|
|
114
|
-
// 后台任务在途集合(background_tasks_changed 为 level 信号,整组替换;ambient 杂务任务不计)。
|
|
115
|
-
// 主 turn result 到达时集合非空 → 不收尾挂起等待:进程一退后台子代理就被连带杀掉,
|
|
116
|
-
// 永远等不到完成通知——后台任务完成会唤醒 CLI 主循环自动续跑,新事件继续流出直到真正收尾
|
|
117
|
-
const bgTasks = new Map();
|
|
118
|
-
// 主 turn 终态已到但仍在等后台任务(true 期间静默超时看门狗生效)
|
|
119
|
-
let waitingBg = false;
|
|
120
|
-
let lastEventAt = Date.now();
|
|
121
|
-
// 启动耗时锚点(见 t0 注释):initAt=system/init 到达;firstOutAt=首个输出信号
|
|
122
|
-
// (首个流式 text delta 或首个完整 assistant 块,先到先记)
|
|
123
|
-
let initAt = 0;
|
|
124
|
-
let firstOutAt = 0;
|
|
125
|
-
let sawFirstDelta = false;
|
|
126
|
-
// init 消息提取的会话清单(每 query 一次);数组字段全部 ?? [] 兜底——SDK 升级字段改名时清单为空但不崩
|
|
127
|
-
let inventory;
|
|
128
|
-
const toolNames = new Map(); // tool_use_id → 工具名(tool_result 块本身不带 name)
|
|
129
|
-
opts.onQuery?.(q);
|
|
130
|
-
// 等待后台任务期间的静默看门狗:pending 期间长时间无任何事件则按已有信息收尾。
|
|
131
|
-
// 经 q.return() 结束 for await(与收到 result 主动 return 走同一条清理路径)
|
|
132
|
-
const silenceWatch = setInterval(() => {
|
|
133
|
-
if (waitingBg && Date.now() - lastEventAt > BG_WAIT_SILENCE_TIMEOUT_MS) {
|
|
134
|
-
console.warn(`[executor] 等待后台任务静默超 ${Math.round(BG_WAIT_SILENCE_TIMEOUT_MS / 60000)} 分钟无事件,按已有结果收尾(后台任务: ${[...bgTasks.values()].map((t) => t.description).join(' / ')})`);
|
|
135
|
-
void q.return(undefined).catch(() => { });
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** 配置指纹(方案 §3.4):全部 spawn 时输入的稳定哈希。这些输入在 executeTask 里逐项
|
|
70
|
+
* 现读(人设/模型/plan/env/配置页 MCP/插件/档案 settings/cwd),常驻进程记录
|
|
71
|
+
* spawn 时指纹,下条消息指纹不符 → 回收重建——与「每任务现读热生效」语义完全一致,
|
|
72
|
+
* 代价只是一次冷启动(退化一次现状体验)。
|
|
73
|
+
* 不入指纹的两类:resumeSessionId——续接目标由复用条件的「指针一致性」单独校验
|
|
74
|
+
* (进程首轮 spawn 必然无 resume、次轮指针即自身会话 id,若入指纹则永远无法复用);
|
|
75
|
+
* canUseTool——gate 为通道级对象,decide 时经 getter 现读配置(热),闭包捕获的
|
|
76
|
+
* wsName 与 cwd 同源(已入指纹) */
|
|
77
|
+
/** 稳定序列化:对象键深度排序(undefined 值剔除)——同内容不同插入序的对象
|
|
78
|
+
* (buildTaskEnv 展开整个 process.env、热重载重建的 env 对象等)不再产生指纹漂移。
|
|
79
|
+
* 漂移 = 无谓的冷启动重建(用户体感「第二条消息还是慢」),实测踩过:仅键序不同
|
|
80
|
+
* 的 env 曾触发一次性「配置指纹变化」重建 */
|
|
81
|
+
function stableStringify(v) {
|
|
82
|
+
if (Array.isArray(v))
|
|
83
|
+
return v.map(stableStringify);
|
|
84
|
+
if (v && typeof v === 'object') {
|
|
85
|
+
const o = {};
|
|
86
|
+
for (const k of Object.keys(v).sort()) {
|
|
87
|
+
const val = v[k];
|
|
88
|
+
if (val !== undefined)
|
|
89
|
+
o[k] = stableStringify(val);
|
|
136
90
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
91
|
+
return o;
|
|
92
|
+
}
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
/** 指纹分量(名称 → 序列化输入):整体哈希用于复用判定;分量哈希存 spawn 快照,
|
|
96
|
+
* 漂移时逐项比对定位「是哪个输入变了」(env 只报键名不报值——env 常含凭证) */
|
|
97
|
+
function fingerprintParts(opts) {
|
|
98
|
+
return [
|
|
99
|
+
['cwd', opts.cwd],
|
|
100
|
+
['env', opts.env ?? null],
|
|
101
|
+
['appendSystemPrompt', opts.appendSystemPrompt ?? null],
|
|
102
|
+
['model', opts.model ?? null],
|
|
103
|
+
['permissionMode', opts.permissionMode ?? null],
|
|
104
|
+
['extraArgs', opts.extraArgs ?? null],
|
|
105
|
+
['allowedSkills', opts.allowedSkills ?? null],
|
|
106
|
+
['planApproveEscalates', opts.planApproveEscalates ?? null],
|
|
107
|
+
['plugins', opts.plugins?.map((p) => p.path) ?? null],
|
|
108
|
+
['mcpServers', serializeMcpServers(opts.mcpServers)],
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
function hashPart(v) {
|
|
112
|
+
return createHash('sha256').update(JSON.stringify(stableStringify(v))).digest('hex');
|
|
113
|
+
}
|
|
114
|
+
function fingerprintOf(opts) {
|
|
115
|
+
return hashPart(fingerprintParts(opts).map(([, v]) => v));
|
|
116
|
+
}
|
|
117
|
+
/** 分量级漂移归因:返回人类可读的变化清单(env 按键名差集,其余按分量名)。
|
|
118
|
+
* 只在已判定指纹不符后调用(诊断输出,不参与判定路径) */
|
|
119
|
+
function describePartDrift(spawn, now) {
|
|
120
|
+
const out = [];
|
|
121
|
+
const nowMap = new Map(now);
|
|
122
|
+
for (const [name, spawnV] of spawn) {
|
|
123
|
+
const nowV = nowMap.get(name);
|
|
124
|
+
if (JSON.stringify(stableStringify(spawnV)) === JSON.stringify(stableStringify(nowV)))
|
|
125
|
+
continue;
|
|
126
|
+
if (name === 'env' && spawnV && nowV && typeof spawnV === 'object' && typeof nowV === 'object') {
|
|
127
|
+
const s = spawnV;
|
|
128
|
+
const n = nowV;
|
|
129
|
+
const added = Object.keys(n).filter((k) => !(k in s));
|
|
130
|
+
const removed = Object.keys(s).filter((k) => !(k in n));
|
|
131
|
+
const changed = Object.keys(s).filter((k) => k in n && stableStringify(s[k]) !== stableStringify(n[k]));
|
|
132
|
+
const bits = [
|
|
133
|
+
added.length ? `新增键 ${added.join(',')}` : '',
|
|
134
|
+
removed.length ? `消失键 ${removed.join(',')}` : '',
|
|
135
|
+
changed.length ? `值变化 ${changed.join(',')}` : '',
|
|
136
|
+
].filter(Boolean);
|
|
137
|
+
out.push(`env(${bits.join(';') || '结构变化'})`);
|
|
146
138
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
139
|
+
else {
|
|
140
|
+
out.push(name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** 推送式异步队列:多轮 prompt 的 streaming input 背板。
|
|
146
|
+
* SDK 按需拉取(generator 挂起等待 push),push 侧无须关心 CLI 是否就绪 */
|
|
147
|
+
class AsyncQueue {
|
|
148
|
+
items = [];
|
|
149
|
+
waiters = [];
|
|
150
|
+
push(v) {
|
|
151
|
+
const w = this.waiters.shift();
|
|
152
|
+
if (w)
|
|
153
|
+
w(v);
|
|
154
|
+
else
|
|
155
|
+
this.items.push(v);
|
|
156
|
+
}
|
|
157
|
+
async *stream() {
|
|
158
|
+
while (true) {
|
|
159
|
+
if (this.items.length > 0)
|
|
160
|
+
yield this.items.shift();
|
|
161
|
+
else
|
|
162
|
+
yield await new Promise((r) => this.waiters.push(r));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 会话进程:一个 CLI 子进程(query)+ 常驻消息循环。
|
|
168
|
+
* 生命周期:spawn → N 轮 sendTurn → kill/die。ephemeral 用法 = spawn 后跑一轮即 kill,
|
|
169
|
+
* 与旧版「每消息一个进程」等价(统一一条循环逻辑,不分叉)。
|
|
170
|
+
*/
|
|
171
|
+
class SessionProcess {
|
|
172
|
+
q;
|
|
173
|
+
key;
|
|
174
|
+
scope;
|
|
175
|
+
resident;
|
|
176
|
+
input = new AsyncQueue();
|
|
177
|
+
abortController = new AbortController();
|
|
178
|
+
spawnT0 = Date.now();
|
|
179
|
+
/** spawn 时的全部输入指纹(复用校验用) */
|
|
180
|
+
fingerprint;
|
|
181
|
+
/** spawn 时的指纹分量快照(漂移归因日志用;env 值不出现在任何日志) */
|
|
182
|
+
spawnParts;
|
|
183
|
+
/** 进程的会话 id(init/result 刷新):复用校验的「指针一致性」基准 */
|
|
184
|
+
sessionId = '';
|
|
185
|
+
/** spawn 时 init 提取的清单(复用轮回放给 onInit;指纹保证清单未变) */
|
|
186
|
+
inventory;
|
|
187
|
+
/** 进程当前权限模式:spawn 初值 + ExitPlanMode 批准升级跟踪;复用轮开始前复位到本轮期望
|
|
188
|
+
* (否则上一轮 plan 批准后进程停留 acceptEdits,本轮 /plan 语义被吃掉) */
|
|
189
|
+
currentMode;
|
|
190
|
+
procInitAt = 0;
|
|
191
|
+
initDone = false;
|
|
192
|
+
currentTurn;
|
|
193
|
+
busy = false;
|
|
194
|
+
dead = false;
|
|
195
|
+
/** 空闲回收判定的活跃基准(每轮收尾刷新) */
|
|
196
|
+
lastUsedAt = Date.now();
|
|
197
|
+
idleMs = DEFAULT_RESIDENT_IDLE_MS;
|
|
198
|
+
turnSignalCleanup;
|
|
199
|
+
/** kill 已执行标记(与 dead 分离:ephemeral 轮内收尾先置 dead,kill 仍须真正 q.return()
|
|
200
|
+
* 结束流迭代——否则真实 SDK 的子进程在无后续消息时永远挂着等 stdin) */
|
|
201
|
+
killed = false;
|
|
202
|
+
constructor(key, opts, resident) {
|
|
203
|
+
this.key = key;
|
|
204
|
+
this.scope = opts.residentScope;
|
|
205
|
+
this.resident = resident;
|
|
206
|
+
this.fingerprint = fingerprintOf(opts);
|
|
207
|
+
this.spawnParts = fingerprintParts(opts);
|
|
208
|
+
this.currentMode = opts.permissionMode ?? 'default';
|
|
209
|
+
const proc = this;
|
|
210
|
+
this.q = query({
|
|
211
|
+
// Streaming Input(官方推荐姿势):prompt 经 AsyncGenerator 送入,push 一条用户消息后
|
|
212
|
+
// 挂起不结束——stdin 在整个进程存活期间保持打开(多轮 = 继续push)。
|
|
213
|
+
// 字符串 prompt 的单轮模式在收到第一条 result 后即关 stdin(SDK isSingleUserTurn
|
|
214
|
+
// 行为,agent-sdk#384),plan mode 下 ExitPlanMode/AskUserQuestion 这类轮次边界的
|
|
215
|
+
// 权限请求落在 result 之后,CLI 侧 inputClosed 后全部报 "Stream closed" 中断审批流
|
|
216
|
+
prompt: this.input.stream(),
|
|
217
|
+
options: {
|
|
218
|
+
cwd: opts.cwd,
|
|
219
|
+
settingSources: ['user', 'project'],
|
|
220
|
+
permissionMode: opts.permissionMode ?? 'default',
|
|
221
|
+
// 流式增量事件(stream_event):仅消费首个 text delta 提前点亮「正在执行」(首个完整块
|
|
222
|
+
// 生成前 otherwise 状态行停留在启动阶段,视觉上拉长「启动中」);不渲染不落盘,
|
|
223
|
+
// 完整块仍走 assistant 分支(渲染/落盘口径不变)
|
|
224
|
+
includePartialMessages: true,
|
|
225
|
+
// 转发子代理 text 块(默认只转发 tool_use/tool_result 心跳)——配合 parent_tool_use_id
|
|
226
|
+
// 区分主/子代理输出,飞书进度卡能看到子代理在做什么
|
|
227
|
+
forwardSubagentText: true,
|
|
228
|
+
// 分身技能白名单(RunTaskOptions.allowedSkills → Options.skills):未列出技能对模型
|
|
229
|
+
// 不可见且被 Skill 工具拒绝;缺省不传 = CLI 默认全量(主机器人行为不变)
|
|
230
|
+
...(opts.allowedSkills?.length ? { skills: opts.allowedSkills } : {}),
|
|
231
|
+
...(opts.resumeSessionId ? { resume: opts.resumeSessionId } : {}),
|
|
232
|
+
...(opts.canUseTool
|
|
233
|
+
? {
|
|
234
|
+
canUseTool: async (toolName, input, ctx) => {
|
|
235
|
+
const decision = await opts.canUseTool(toolName, input, { suggestions: ctx?.suggestions });
|
|
236
|
+
// 计划批准即授权写操作:对齐 Claude Code CLI「plan 批准 → accept edits on」语义,
|
|
237
|
+
// 后续 Write/Edit 不再逐次弹确认卡(SDK Query.setPermissionMode,官方 permissions 文档姿势)。
|
|
238
|
+
// 分身(planApproveEscalates=false)不升级:acceptEdits 会绕过 canUseTool,
|
|
239
|
+
// 宿主安全闸(R1 红区/R2 写边界)随之失效——安全优先于确认摩擦。
|
|
240
|
+
// 模式跟踪:常驻进程跨轮存续,复用轮开始前据此复位(sendTurn)
|
|
241
|
+
if (decision.behavior === 'allow' && toolName === 'ExitPlanMode'
|
|
242
|
+
&& opts.planApproveEscalates !== false) {
|
|
243
|
+
await proc.q.setPermissionMode('acceptEdits').then(() => { proc.currentMode = 'acceptEdits'; }).catch((e) => {
|
|
244
|
+
console.warn('[executor] setPermissionMode(acceptEdits) 失败(继续按批准前模式执行):', e instanceof Error ? e.message : e);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return decision;
|
|
248
|
+
},
|
|
175
249
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
250
|
+
: {}),
|
|
251
|
+
abortController: this.abortController,
|
|
252
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
253
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
254
|
+
...(opts.mcpServers ? { mcpServers: opts.mcpServers } : {}),
|
|
255
|
+
...(opts.plugins ? { plugins: opts.plugins.map((p) => ({ type: 'local', path: p.path })) } : {}),
|
|
256
|
+
// extraArgs 合并口:调用方传入(--settings 档案认证)+ 人格补充(--append-system-prompt,
|
|
257
|
+
// CLI 2.1.x 只认命令行参数,SDK 同名 option 被静默丢弃——见 RunTaskOptions.appendSystemPrompt 注释)
|
|
258
|
+
...((opts.extraArgs && Object.keys(opts.extraArgs).length) || opts.appendSystemPrompt
|
|
259
|
+
? {
|
|
260
|
+
extraArgs: {
|
|
261
|
+
...(opts.extraArgs ?? {}),
|
|
262
|
+
...(opts.appendSystemPrompt ? { 'append-system-prompt': opts.appendSystemPrompt } : {}),
|
|
263
|
+
},
|
|
185
264
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
265
|
+
: {}),
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
static spawn(key, opts, resident) {
|
|
270
|
+
const proc = new SessionProcess(key, opts, resident);
|
|
271
|
+
void proc.runLoop();
|
|
272
|
+
return proc;
|
|
273
|
+
}
|
|
274
|
+
get isBusy() { return this.busy; }
|
|
275
|
+
/** 常驻消息循环:进程存活期间常驻,事件按 currentTurn 分发;轮结束只 settle 轮次,
|
|
276
|
+
* 不退出循环(ephemeral 进程在首轮收尾后 break,对齐旧版「result 即结束」) */
|
|
277
|
+
async runLoop() {
|
|
278
|
+
try {
|
|
279
|
+
for await (const message of this.q) {
|
|
280
|
+
if (this.dead)
|
|
281
|
+
break; // kill 已发起:不再消费
|
|
282
|
+
const turn = this.currentTurn;
|
|
283
|
+
if (!turn)
|
|
284
|
+
continue; // 轮外杂事件(上轮尾巴的 idle 心跳等):静默丢弃
|
|
285
|
+
turn.lastEventAt = Date.now();
|
|
286
|
+
switch (message.type) {
|
|
287
|
+
case 'system': {
|
|
288
|
+
if (message.subtype === 'init') {
|
|
289
|
+
this.procInitAt = Date.now();
|
|
290
|
+
this.initDone = true;
|
|
291
|
+
turn.initAt = Date.now();
|
|
292
|
+
// init 即携带 session_id:全新会话在首轮 turn 结束前被 /stop 中止时不会有 result 消息,
|
|
293
|
+
// 不在此捕获则 sessionId 恒为空串 → catch 挂不上 err.sessionId → 归档 no-op,
|
|
294
|
+
// 会话指针停留 null,用户随后「继续」会开全新会话丢失上下文(resume 场景两值等价)
|
|
295
|
+
if (typeof message.session_id === 'string' && message.session_id) {
|
|
296
|
+
this.sessionId = message.session_id;
|
|
297
|
+
turn.sessionId = message.session_id;
|
|
298
|
+
}
|
|
299
|
+
this.inventory = {
|
|
300
|
+
model: message.model,
|
|
301
|
+
claudeCodeVersion: message.claude_code_version,
|
|
302
|
+
// 顶层已注册工具清单(dynamic tool loading 下核心工具在顶层、其余经 ToolSearch 按需加载,
|
|
303
|
+
// ToolSearch 索引不到顶层工具):/status 展示与「工具不存在」误判诊断的数据源
|
|
304
|
+
tools: message.tools ?? [],
|
|
305
|
+
skills: message.skills ?? [],
|
|
306
|
+
slashCommands: message.slash_commands ?? [],
|
|
307
|
+
plugins: (message.plugins ?? []).map((p) => ({ name: p.name, path: p.path, version: p.version })),
|
|
308
|
+
mcpServers: message.mcp_servers ?? [],
|
|
309
|
+
agents: message.agents ?? [],
|
|
310
|
+
};
|
|
311
|
+
turn.cb.onInit?.(this.inventory);
|
|
193
312
|
}
|
|
194
|
-
if (
|
|
195
|
-
|
|
313
|
+
else if (message.subtype === 'status') {
|
|
314
|
+
// CLI 状态心跳(requesting=等模型响应 / compacting=上下文压缩 / null=空闲):刷新进度卡
|
|
315
|
+
// 状态行——includePartialMessages=false 下长 API 轮次没有任何其他事件,避免「只有计时在走」的黑盒感
|
|
316
|
+
await turn.cb.onProgress({
|
|
317
|
+
kind: 'status',
|
|
318
|
+
content: message.status === 'requesting' ? '🌐 等待模型响应…'
|
|
319
|
+
: message.status === 'compacting' ? '🗜️ 上下文压缩中…'
|
|
320
|
+
: '🔄 运行中',
|
|
321
|
+
});
|
|
196
322
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
323
|
+
else if (message.subtype === 'background_tasks_changed') {
|
|
324
|
+
// 后台任务在途清单(level 信号):整组替换语义。ambient=true 的杂务任务
|
|
325
|
+
//(live-update watcher 等)不算用户可见工作,不计数避免状态行虚高
|
|
326
|
+
turn.bgTasks.clear();
|
|
327
|
+
for (const t of message.tasks) {
|
|
328
|
+
if (!t.ambient)
|
|
329
|
+
turn.bgTasks.set(t.task_id, { taskType: t.task_type, description: t.description });
|
|
330
|
+
}
|
|
331
|
+
if (turn.waitingBg && turn.bgTasks.size > 0) {
|
|
332
|
+
await turn.cb.onProgress({ kind: 'status', content: `⏳ 等待 ${turn.bgTasks.size} 个后台任务完成…` });
|
|
333
|
+
}
|
|
204
334
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// 子代理清单:进度卡独立区块展示每个子代理的运行状态(对齐 CLI 的 agent 进度显示)
|
|
213
|
-
await cb.onProgress({
|
|
214
|
-
kind: 'agent-start',
|
|
215
|
-
agent: { id: message.task_id, description: message.description, type: message.subagent_type ?? 'task' },
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
else if (message.subtype === 'task_notification') {
|
|
219
|
-
// 后台任务落定(completed/failed/stopped):summary 为任务自述结论。
|
|
220
|
-
// status 会原文渲染进进度卡正文并随每次卡片更新发送——子代理完整报告可达
|
|
221
|
-
// 上万字符,截断保留结论摘要(全文另有 agent 清单与 transcript 承载)
|
|
222
|
-
const label = message.status === 'completed' ? '✅ 后台任务完成' : message.status === 'failed' ? '❌ 后台任务失败' : '🛑 后台任务已停止';
|
|
223
|
-
await cb.onProgress({ kind: 'status', content: `${label}: ${(message.summary ?? '').slice(0, 300)}` });
|
|
224
|
-
await cb.onProgress({
|
|
225
|
-
kind: 'agent-settle',
|
|
226
|
-
id: message.task_id,
|
|
227
|
-
status: message.status === 'completed' ? 'done' : message.status === 'failed' ? 'failed' : 'stopped',
|
|
228
|
-
...(message.summary ? { summary: message.summary } : {}),
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
case 'stream_event': {
|
|
234
|
-
// 流式增量(includePartialMessages):仅用首个 text delta 提前点亮「正在执行」——
|
|
235
|
-
// 完整块生成前状态行 otherwise 停留在启动阶段。按 Anthropic Messages stream 事件
|
|
236
|
-
// 结构窄化访问,字段缺失/其他事件类型静默忽略
|
|
237
|
-
const ev = message.event;
|
|
238
|
-
if (!sawFirstDelta && ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
|
239
|
-
sawFirstDelta = true;
|
|
240
|
-
if (!firstOutAt)
|
|
241
|
-
firstOutAt = Date.now();
|
|
242
|
-
await cb.onProgress({ kind: 'first-delta' });
|
|
243
|
-
}
|
|
244
|
-
break;
|
|
245
|
-
}
|
|
246
|
-
case 'assistant': {
|
|
247
|
-
if (!firstOutAt)
|
|
248
|
-
firstOutAt = Date.now();
|
|
249
|
-
// turns 只计主循环轮次:子代理消息(parent_tool_use_id 非空)混入会把单任务轮数
|
|
250
|
-
// 放大数倍(旧超长提醒误报的帮凶),兜底口径应以主循环为准
|
|
251
|
-
if (!message.parent_tool_use_id)
|
|
252
|
-
turns++;
|
|
253
|
-
for (const block of message.message.content) {
|
|
254
|
-
if (block.type === 'text') {
|
|
255
|
-
// 子代理文本加标识前缀(forwardSubagentText 转发),与主 Agent 输出在进度卡上区分
|
|
256
|
-
const prefix = message.parent_tool_use_id ? '🤖 ' : '';
|
|
257
|
-
await cb.onProgress({ kind: 'text', content: `${prefix}${block.text}` });
|
|
335
|
+
else if (message.subtype === 'session_state_changed' && message.state === 'idle') {
|
|
336
|
+
// 权威 turn-over 信号:idle 在 heldBackResult flush 且后台任务续跑循环退出后触发。
|
|
337
|
+
// 主 turn result 已到 + 后台任务全部结束 → 真正收尾(后台任务若再唤醒主循环,
|
|
338
|
+
// 会先收到新的 assistant/result 事件并覆盖 finalText,idle 仍在最后)
|
|
339
|
+
if (turn.waitingBg && turn.bgTasks.size === 0) {
|
|
340
|
+
await this.finalizeTurn(turn);
|
|
341
|
+
}
|
|
258
342
|
}
|
|
259
|
-
else if (
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
343
|
+
else if (message.subtype === 'task_started') {
|
|
344
|
+
const tag = message.subagent_type ? ` [${message.subagent_type}]` : '';
|
|
345
|
+
await turn.cb.onProgress({
|
|
346
|
+
kind: 'status',
|
|
347
|
+
content: `${message.is_backgrounded ? '🤖 后台子代理启动' : '🤖 子代理启动'}${tag}: ${message.description}`,
|
|
348
|
+
});
|
|
349
|
+
// 子代理清单:进度卡独立区块展示每个子代理的运行状态(对齐 CLI 的 agent 进度显示)
|
|
350
|
+
await turn.cb.onProgress({
|
|
351
|
+
kind: 'agent-start',
|
|
352
|
+
agent: { id: message.task_id, description: message.description, type: message.subagent_type ?? 'task' },
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
else if (message.subtype === 'task_notification') {
|
|
356
|
+
// 后台任务落定(completed/failed/stopped):summary 为任务自述结论。
|
|
357
|
+
// status 会原文渲染进进度卡正文并随每次卡片更新发送——子代理完整报告可达
|
|
358
|
+
// 上万字符,截断保留结论摘要(全文另有 agent 清单与 transcript 承载)
|
|
359
|
+
const label = message.status === 'completed' ? '✅ 后台任务完成' : message.status === 'failed' ? '❌ 后台任务失败' : '🛑 后台任务已停止';
|
|
360
|
+
await turn.cb.onProgress({ kind: 'status', content: `${label}: ${(message.summary ?? '').slice(0, 300)}` });
|
|
361
|
+
await turn.cb.onProgress({
|
|
362
|
+
kind: 'agent-settle',
|
|
363
|
+
id: message.task_id,
|
|
364
|
+
status: message.status === 'completed' ? 'done' : message.status === 'failed' ? 'failed' : 'stopped',
|
|
365
|
+
...(message.summary ? { summary: message.summary } : {}),
|
|
366
|
+
});
|
|
264
367
|
}
|
|
265
|
-
}
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
case 'user': {
|
|
269
|
-
const content = message.message.content;
|
|
270
|
-
if (!Array.isArray(content))
|
|
271
368
|
break;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
369
|
+
}
|
|
370
|
+
case 'stream_event': {
|
|
371
|
+
// 流式增量(includePartialMessages):仅用首个 text delta 提前点亮「正在执行」——
|
|
372
|
+
// 完整块生成前状态行 otherwise 停留在启动阶段。按 Anthropic Messages stream 事件
|
|
373
|
+
// 结构窄化访问,字段缺失/其他事件类型静默忽略
|
|
374
|
+
const ev = message.event;
|
|
375
|
+
if (!turn.sawFirstDelta && ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
|
376
|
+
turn.sawFirstDelta = true;
|
|
377
|
+
if (turn.initAt && !turn.firstOutAt)
|
|
378
|
+
turn.firstOutAt = Date.now();
|
|
379
|
+
await turn.cb.onProgress({ kind: 'first-delta' });
|
|
277
380
|
}
|
|
381
|
+
break;
|
|
278
382
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
383
|
+
case 'assistant': {
|
|
384
|
+
// P2 修正:输出信号只认 init 之后的(resume 回放的 assistant 块/时钟漂移不再产生
|
|
385
|
+
// 负数 init→首输出——统计被离群污染的根因)。渲染/轮次计数不受影响
|
|
386
|
+
if (turn.initAt && !turn.firstOutAt)
|
|
387
|
+
turn.firstOutAt = Date.now();
|
|
388
|
+
// turns 只计主循环轮次:子代理消息(parent_tool_use_id 非空)混入会把单任务轮数
|
|
389
|
+
// 放大数倍(旧超长提醒误报的帮凶),兜底口径应以主循环为准
|
|
390
|
+
if (!message.parent_tool_use_id)
|
|
391
|
+
turn.turns++;
|
|
392
|
+
for (const block of message.message.content) {
|
|
393
|
+
if (block.type === 'text') {
|
|
394
|
+
// 子代理文本加标识前缀(forwardSubagentText 转发),与主 Agent 输出在进度卡上区分
|
|
395
|
+
const prefix = message.parent_tool_use_id ? '🤖 ' : '';
|
|
396
|
+
await turn.cb.onProgress({ kind: 'text', content: `${prefix}${block.text}` });
|
|
397
|
+
}
|
|
398
|
+
else if (block.type === 'tool_use') {
|
|
399
|
+
const input = (block.input ?? {});
|
|
400
|
+
turn.toolNames.set(block.id, block.name);
|
|
401
|
+
turn.collector.track(block.name, input);
|
|
402
|
+
await turn.cb.onProgress({ kind: 'tool-start', content: `${block.name}: ${summarizeToolInput(input)}` });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
297
406
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// 后台子代理被连带杀掉、完成通知永远收不到(旧版「主代理结束、子代理无声消失」根因)。
|
|
302
|
-
// 挂起继续 for await:后台任务完成会唤醒 CLI 主循环自动续跑(新的 assistant/result
|
|
303
|
-
// 事件照常流出,飞书端继续收到推送),直到 session_state_changed(idle) 或续跑 turn 的
|
|
304
|
-
// result 在 bgTasks 清空后到达才真正收尾。bgTasks 为空 = 无在途任务,维持旧行为立即收尾
|
|
305
|
-
//(CLI 不发 background_tasks_changed 事件时自然退化,不会比旧版更差)
|
|
306
|
-
if (message.subtype === 'success' || message.subtype.startsWith('error')) {
|
|
307
|
-
if (bgTasks.size > 0) {
|
|
308
|
-
waitingBg = true;
|
|
309
|
-
await cb.onProgress({ kind: 'status', content: `⏳ 主线回复完成,等待 ${bgTasks.size} 个后台任务…` });
|
|
407
|
+
case 'user': {
|
|
408
|
+
const content = message.message.content;
|
|
409
|
+
if (!Array.isArray(content))
|
|
310
410
|
break;
|
|
411
|
+
for (const block of content) {
|
|
412
|
+
if (block.type === 'tool_result') {
|
|
413
|
+
const name = turn.toolNames.get(block.tool_use_id) ?? '';
|
|
414
|
+
const note = block.is_error ? firstErrorLine(block.content) : undefined;
|
|
415
|
+
await turn.cb.onProgress({ kind: 'tool-result', content: name, ok: !block.is_error, ...(note ? { note } : {}) });
|
|
416
|
+
}
|
|
311
417
|
}
|
|
312
|
-
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case 'result': {
|
|
421
|
+
if (message.subtype === 'success')
|
|
422
|
+
turn.finalText = message.result;
|
|
423
|
+
// 非成功终态(error_during_execution / error_max_turns 等,错误文本在 errors 数组):
|
|
424
|
+
// 带出循环后统一抛出,消息格式与单轮模式 SDK throw 保持一致(index.ts catch 的 hint 依赖该格式)
|
|
425
|
+
else if (message.subtype.startsWith('error'))
|
|
426
|
+
turn.resultErrorText = message.errors?.join('\n') || message.subtype;
|
|
427
|
+
// usage 提取(error result 可能缺字段,全部兜 0 不抛)
|
|
428
|
+
const u = message.usage;
|
|
429
|
+
if (u) {
|
|
430
|
+
turn.usage = {
|
|
431
|
+
inputTokens: u.input_tokens ?? 0,
|
|
432
|
+
outputTokens: u.output_tokens ?? 0,
|
|
433
|
+
cacheCreationInputTokens: u.cache_creation_input_tokens ?? 0,
|
|
434
|
+
cacheReadInputTokens: u.cache_read_input_tokens ?? 0,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if ('session_id' in message && typeof message.session_id === 'string') {
|
|
438
|
+
this.sessionId = message.session_id;
|
|
439
|
+
turn.sessionId = message.session_id;
|
|
440
|
+
}
|
|
441
|
+
// 主 turn 终态。后台任务仍在跑时不收尾:return 会触发 q.return() → SDK 清理子进程,
|
|
442
|
+
// 后台子代理被连带杀掉、完成通知永远收不到(旧版「主代理结束、子代理无声消失」根因)。
|
|
443
|
+
// 挂起继续 for await:后台任务完成会唤醒 CLI 主循环自动续跑(新的 assistant/result
|
|
444
|
+
// 事件照常流出,飞书端继续收到推送),直到 session_state_changed(idle) 或续跑 turn 的
|
|
445
|
+
// result 在 bgTasks 清空后到达才真正收尾。bgTasks 为空 = 无在途任务,维持旧行为立即收尾
|
|
446
|
+
//(CLI 不发 background_tasks_changed 事件时自然退化,不会比旧版更差)
|
|
447
|
+
if (message.subtype === 'success' || message.subtype.startsWith('error')) {
|
|
448
|
+
if (turn.bgTasks.size > 0) {
|
|
449
|
+
turn.waitingBg = true;
|
|
450
|
+
await turn.cb.onProgress({ kind: 'status', content: `⏳ 主线回复完成,等待 ${turn.bgTasks.size} 个后台任务…` });
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
await this.finalizeTurn(turn);
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
313
456
|
}
|
|
314
|
-
break;
|
|
315
457
|
}
|
|
316
458
|
}
|
|
459
|
+
// 流自然结束(CLI 进程退出/被 kill):轮内未收尾的按进程退出错误结算。
|
|
460
|
+
// kill 路径(/stop、回收)按中止语义(wiring 的 abort 分支命中);非 kill 的自然
|
|
461
|
+
// EOF 在 result 之前到达属进程崩溃——明确报因,不再像旧版那样静默返回空成功
|
|
462
|
+
if (this.currentTurn && !this.currentTurn.settled) {
|
|
463
|
+
const err = new Error(this.killed ? 'Operation aborted' : 'Claude Code 进程在回复完成前退出(可重发任务;历史上下文经 /resume 可找回)');
|
|
464
|
+
this.settleTurnError(this.currentTurn, err);
|
|
465
|
+
}
|
|
466
|
+
this.die();
|
|
317
467
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
468
|
+
catch (e) {
|
|
469
|
+
// 流错误(/stop 的 AbortError、Stream closed、进程崩溃等):把已知的 sessionId 挂到
|
|
470
|
+
// 错误对象再抛——wiring 侧据此把中止/出错任务的会话也归档进 /resume 历史
|
|
471
|
+
//(AbortError 自身不携带 sessionId;不改动原 message,index.ts catch 的 hint 匹配不受影响)
|
|
472
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
473
|
+
if (this.currentTurn && !this.currentTurn.settled)
|
|
474
|
+
this.settleTurnError(this.currentTurn, err);
|
|
475
|
+
this.die();
|
|
326
476
|
}
|
|
327
|
-
throw err;
|
|
328
477
|
}
|
|
329
|
-
|
|
330
|
-
|
|
478
|
+
/** 收尾前拉一次 MCP 真实连接状态(init 快照必为 pending,这是空闲期 /mcp 的数据源);
|
|
479
|
+
* try/catch 同时兜住同步异常(旧 mock/异常环境下方法缺失)与异步拒绝,绝不阻断收尾 */
|
|
480
|
+
async finalizeTurn(turn) {
|
|
481
|
+
try {
|
|
482
|
+
const statuses = await this.q.mcpServerStatus();
|
|
483
|
+
turn.cb.onMcpStatus?.(statuses);
|
|
484
|
+
}
|
|
485
|
+
catch { /* 拉取失败:保留 init 快照 */ }
|
|
486
|
+
if (turn.resultErrorText) {
|
|
487
|
+
// 携带 sessionId 抛出:wiring 侧据此把出错任务的会话也归档进 /resume 历史
|
|
488
|
+
//(错误任务的会话上下文同样有价值;早期不归档导致历史列表难积累)
|
|
489
|
+
const err = new Error(`Claude Code returned an error result: ${turn.resultErrorText}`);
|
|
490
|
+
err.sessionId = turn.sessionId || undefined;
|
|
491
|
+
this.settleTurn(turn, err);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.settleTurn(turn, {
|
|
495
|
+
sessionId: turn.sessionId,
|
|
496
|
+
finalText: turn.finalText,
|
|
497
|
+
producedFiles: turn.collector.files(),
|
|
498
|
+
turns: turn.turns,
|
|
499
|
+
...(turn.usage ? { usage: turn.usage } : {}),
|
|
500
|
+
...(this.inventory ? { inventory: this.inventory } : {}),
|
|
501
|
+
});
|
|
331
502
|
}
|
|
332
|
-
|
|
333
|
-
|
|
503
|
+
/** 轮次落定(成功/出错共用):清看门狗与信号监听、复位 busy、(ephemeral)停止循环 */
|
|
504
|
+
settleTurn(turn, result) {
|
|
505
|
+
if (turn.settled)
|
|
506
|
+
return;
|
|
507
|
+
turn.settled = true;
|
|
508
|
+
clearInterval(turn.silenceWatch);
|
|
509
|
+
this.turnSignalCleanup?.();
|
|
510
|
+
this.turnSignalCleanup = undefined;
|
|
511
|
+
if (this.currentTurn === turn)
|
|
512
|
+
this.currentTurn = undefined;
|
|
513
|
+
this.busy = false;
|
|
514
|
+
this.lastUsedAt = Date.now();
|
|
334
515
|
// 启动耗时分解(先诊断承诺):spawn→init 是 CLI 冷启动(配置/插件/MCP server 拉起 +
|
|
335
|
-
// resume
|
|
516
|
+
// resume 回放);复用轮为 0s(进程早已就绪)。init→首输出是模型首响(P2 修正后仅计
|
|
517
|
+
// init 之后的信号,不再被 resume 回放块污染成负数)。总耗时按本轮 push 时刻起算
|
|
336
518
|
const sec = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
519
|
+
const initLag = turn.reused ? '0s(常驻复用)'
|
|
520
|
+
: this.procInitAt ? sec(this.procInitAt - this.spawnT0) : '?';
|
|
521
|
+
const firstOut = turn.initAt && turn.firstOutAt && turn.firstOutAt >= turn.initAt ? sec(turn.firstOutAt - turn.initAt) : '?';
|
|
522
|
+
console.log(`[executor] 启动耗时:spawn→init ${initLag},init→首输出 ${firstOut},总 ${sec(Date.now() - turn.t0)}${turn.reused ? '(常驻复用)' : this.resident ? '(新进程)' : '(每消息进程)'}`);
|
|
523
|
+
if (result instanceof Error)
|
|
524
|
+
turn.reject(result);
|
|
525
|
+
else
|
|
526
|
+
turn.resolve(result);
|
|
527
|
+
// ephemeral:首轮收尾即停(对齐旧版「result 即 return 结束循环」)
|
|
528
|
+
if (!this.resident)
|
|
529
|
+
this.dead = true;
|
|
530
|
+
}
|
|
531
|
+
/** settleTurn 的错误入口:附 sessionId 后 reject(复用 catch 路径的挂载语义) */
|
|
532
|
+
settleTurnError(turn, err) {
|
|
533
|
+
if (turn.sessionId && !err.sessionId) {
|
|
534
|
+
err.sessionId = turn.sessionId;
|
|
535
|
+
}
|
|
536
|
+
this.settleTurn(turn, err);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 送入一轮任务(prompt 经 stdin 流写入同一进程),返回本轮结果。
|
|
540
|
+
* 复用轮开始前做三件事:回放 init 清单(无新 init 消息)、重曝光 query 句柄、
|
|
541
|
+
* 复位权限模式(上一轮 plan 批准可能已把进程切到 acceptEdits)。
|
|
542
|
+
*/
|
|
543
|
+
async sendTurn(prompt, opts, cb) {
|
|
544
|
+
if (this.dead) {
|
|
545
|
+
const err = new Error('Operation aborted');
|
|
546
|
+
if (this.sessionId)
|
|
547
|
+
err.sessionId = this.sessionId;
|
|
343
548
|
throw err;
|
|
344
549
|
}
|
|
345
|
-
|
|
550
|
+
this.busy = true;
|
|
551
|
+
this.lastUsedAt = Date.now();
|
|
552
|
+
this.idleMs = opts.residentIdleMs ?? DEFAULT_RESIDENT_IDLE_MS;
|
|
553
|
+
const reused = this.initDone;
|
|
554
|
+
// 本轮 abort 信号接线:外部 signal(/stop、4h 硬超时)中止 = 杀整个进程
|
|
555
|
+
//(与旧行为等价:abortController.abort 终止整个 query),下条消息冷启重建。
|
|
556
|
+
// 直接桥接(signal → abortController)与旧 bridgeSignal 一致、不随轮清理——
|
|
557
|
+
// 「任务结束后 signal 仍传导到进程控制器」是既有可观测行为(wiring 侧不会再触发)
|
|
558
|
+
if (opts.signal) {
|
|
559
|
+
if (opts.signal.aborted) {
|
|
560
|
+
const err = new Error('Operation aborted');
|
|
561
|
+
if (this.sessionId)
|
|
562
|
+
err.sessionId = this.sessionId;
|
|
563
|
+
this.busy = false;
|
|
564
|
+
void this.kill();
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
const signal = opts.signal;
|
|
568
|
+
signal.addEventListener('abort', () => this.abortController.abort(new Error('task-aborted')), { once: true });
|
|
569
|
+
const onAbort = () => { void this.kill(); };
|
|
570
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
571
|
+
this.turnSignalCleanup = () => signal.removeEventListener('abort', onAbort);
|
|
572
|
+
}
|
|
573
|
+
const proc = this;
|
|
574
|
+
const turn = {
|
|
575
|
+
prompt,
|
|
576
|
+
cb,
|
|
577
|
+
collector: new OutputCollector(opts.cwd),
|
|
578
|
+
resolve: () => { },
|
|
579
|
+
reject: () => { },
|
|
580
|
+
settled: false,
|
|
581
|
+
t0: Date.now(),
|
|
582
|
+
initAt: 0,
|
|
583
|
+
firstOutAt: 0,
|
|
584
|
+
sawFirstDelta: false,
|
|
585
|
+
finalText: '',
|
|
586
|
+
resultErrorText: '',
|
|
587
|
+
usage: undefined,
|
|
588
|
+
turns: 0,
|
|
589
|
+
sessionId: opts.resumeSessionId ?? '',
|
|
590
|
+
reused,
|
|
591
|
+
bgTasks: new Map(),
|
|
592
|
+
waitingBg: false,
|
|
593
|
+
lastEventAt: Date.now(),
|
|
594
|
+
toolNames: new Map(),
|
|
595
|
+
// 等待后台任务期间的静默看门狗:pending 期间长时间无任何事件则按已有信息收尾
|
|
596
|
+
//(并把进程一并回收——15 分钟静默属异常态,进程可信度存疑,冷启重建更稳)
|
|
597
|
+
silenceWatch: setInterval(() => {
|
|
598
|
+
const t = proc.currentTurn;
|
|
599
|
+
if (t === turn && t.waitingBg && Date.now() - t.lastEventAt > BG_WAIT_SILENCE_TIMEOUT_MS) {
|
|
600
|
+
console.warn(`[executor] 等待后台任务静默超 ${Math.round(BG_WAIT_SILENCE_TIMEOUT_MS / 60000)} 分钟无事件,按已有结果收尾(后台任务: ${[...t.bgTasks.values()].map((x) => x.description).join(' / ')})`);
|
|
601
|
+
proc.settleTurn(t, {
|
|
602
|
+
sessionId: t.sessionId,
|
|
603
|
+
finalText: t.finalText,
|
|
604
|
+
producedFiles: t.collector.files(),
|
|
605
|
+
turns: t.turns,
|
|
606
|
+
...(t.usage ? { usage: t.usage } : {}),
|
|
607
|
+
...(proc.inventory ? { inventory: proc.inventory } : {}),
|
|
608
|
+
});
|
|
609
|
+
void proc.kill();
|
|
610
|
+
}
|
|
611
|
+
}, 30_000),
|
|
612
|
+
};
|
|
613
|
+
turn.silenceWatch.unref?.();
|
|
614
|
+
if (reused) {
|
|
615
|
+
// 复用轮:进程早已 init —— 本轮「启动」为 0s;init 清单回放(指纹保证未变)+
|
|
616
|
+
// 句柄重曝光 + 权限模式复位到本轮期望
|
|
617
|
+
turn.initAt = Date.now();
|
|
618
|
+
if (this.inventory)
|
|
619
|
+
cb.onInit?.(this.inventory);
|
|
620
|
+
opts.onQuery?.(this.q);
|
|
621
|
+
const want = opts.permissionMode ?? 'default';
|
|
622
|
+
if (want !== this.currentMode) {
|
|
623
|
+
await this.q.setPermissionMode(want).then(() => { this.currentMode = want; }).catch((e) => {
|
|
624
|
+
console.warn('[executor] 复用轮 setPermissionMode 复位失败(继续按进程当前模式执行):', e instanceof Error ? e.message : e);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
opts.onQuery?.(this.q);
|
|
630
|
+
}
|
|
631
|
+
const promise = new Promise((resolve, reject) => {
|
|
632
|
+
turn.resolve = resolve;
|
|
633
|
+
turn.reject = reject;
|
|
634
|
+
});
|
|
635
|
+
this.currentTurn = turn;
|
|
636
|
+
this.input.push({ type: 'user', message: { role: 'user', content: prompt }, parent_tool_use_id: null });
|
|
637
|
+
return promise;
|
|
638
|
+
}
|
|
639
|
+
/** 主动回收:结束流迭代 + 中止在途控制请求,进程出池。
|
|
640
|
+
* 忙碌进程不应被回收(会杀掉运行中任务)——调用方自查 isBusy */
|
|
641
|
+
async kill() {
|
|
642
|
+
if (this.killed)
|
|
643
|
+
return;
|
|
644
|
+
this.killed = true;
|
|
645
|
+
this.dead = true;
|
|
646
|
+
if (pool.get(this.key) === this)
|
|
647
|
+
pool.delete(this.key);
|
|
648
|
+
// 本轮仍在途时同步 abort(唤醒在途等待;外部 signal 路径的直达桥接已先行触发);
|
|
649
|
+
// 空闲回收不 abort——结束迭代本身即触发 SDK 子进程清理,且保持「任务完成后
|
|
650
|
+
// 控制器不被污染」的既有可观测语义
|
|
651
|
+
if (this.currentTurn)
|
|
652
|
+
this.abortController.abort(new Error('process-recycled'));
|
|
653
|
+
try {
|
|
654
|
+
await this.q.return(undefined);
|
|
655
|
+
}
|
|
656
|
+
catch { /* 进程已退等场景:忽略 */ }
|
|
657
|
+
}
|
|
658
|
+
/** 非主动的死亡(流结束/崩溃):出池即可,迭代已自然结束 */
|
|
659
|
+
die() {
|
|
660
|
+
if (this.dead)
|
|
661
|
+
return;
|
|
662
|
+
this.dead = true;
|
|
663
|
+
if (pool.get(this.key) === this)
|
|
664
|
+
pool.delete(this.key);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// ---- 常驻进程池(模块级,跨 app 共享;key 带 appId 前缀防撞) ----
|
|
668
|
+
const pool = new Map();
|
|
669
|
+
let sweeper = null;
|
|
670
|
+
/** 空闲回收巡检:每分钟扫一遍池,空闲超阈值的空闲进程回收。
|
|
671
|
+
* 复用路径还有内联检查(消息到达时判定),巡检负责「之后再无消息」的内存释放 */
|
|
672
|
+
function ensureSweeper() {
|
|
673
|
+
if (sweeper)
|
|
674
|
+
return;
|
|
675
|
+
sweeper = setInterval(() => {
|
|
676
|
+
const now = Date.now();
|
|
677
|
+
for (const proc of [...pool.values()]) {
|
|
678
|
+
if (!proc.isBusy && proc.idleMs > 0 && now - proc.lastUsedAt > proc.idleMs) {
|
|
679
|
+
console.log(`[executor] 常驻进程空闲回收:${proc.key}(空闲 ${Math.round((now - proc.lastUsedAt) / 60000)} 分钟)`);
|
|
680
|
+
void proc.kill();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (pool.size === 0 && sweeper) {
|
|
684
|
+
clearInterval(sweeper);
|
|
685
|
+
sweeper = null;
|
|
686
|
+
}
|
|
687
|
+
}, 60_000);
|
|
688
|
+
sweeper.unref?.();
|
|
689
|
+
}
|
|
690
|
+
/** 进程注册入池 + 上限约束(按 scope 统计,LRU 淘汰最久未用的空闲进程;忙进程不动) */
|
|
691
|
+
function register(proc, max) {
|
|
692
|
+
pool.set(proc.key, proc);
|
|
693
|
+
if (max && max > 0) {
|
|
694
|
+
const others = [...pool.values()].filter((p) => p !== proc && p.scope === proc.scope);
|
|
695
|
+
let over = others.length + 1 - max;
|
|
696
|
+
if (over > 0) {
|
|
697
|
+
const idleOldestFirst = others.filter((p) => !p.isBusy).sort((a, b) => a.lastUsedAt - b.lastUsedAt);
|
|
698
|
+
for (const victim of idleOldestFirst) {
|
|
699
|
+
if (over-- <= 0)
|
|
700
|
+
break;
|
|
701
|
+
console.log(`[executor] 常驻进程池达上限(${max}),LRU 回收:${victim.key}`);
|
|
702
|
+
void victim.kill();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
ensureSweeper();
|
|
707
|
+
}
|
|
708
|
+
/** 指针切换时作废该通道的常驻进程(/new、/resume <n>、/ws use):旧进程的会话上下文
|
|
709
|
+
* 不再适用。忙进程不动——运行中任务的中止由 /new 的 abort 负责(abort 会连带杀进程) */
|
|
710
|
+
export function killResidentSession(affinityKey) {
|
|
711
|
+
const proc = pool.get(affinityKey);
|
|
712
|
+
if (proc && !proc.isBusy)
|
|
713
|
+
void proc.kill();
|
|
714
|
+
}
|
|
715
|
+
/** 桥接器关闭时回收全部常驻进程(优雅退出,不赖 stdio 断连兜底) */
|
|
716
|
+
export function killAllResidentSessions() {
|
|
717
|
+
for (const proc of [...pool.values()])
|
|
718
|
+
void proc.kill();
|
|
719
|
+
}
|
|
720
|
+
/** 测试隔离:清空进程池(生产代码不得调用) */
|
|
721
|
+
export function __resetResidentPoolForTests() {
|
|
722
|
+
for (const proc of [...pool.values()])
|
|
723
|
+
void proc.kill();
|
|
724
|
+
pool.clear();
|
|
725
|
+
}
|
|
726
|
+
export async function runTask(prompt, opts, cb) {
|
|
727
|
+
const key = opts.affinityKey;
|
|
728
|
+
if (!key || opts.resident === false) {
|
|
729
|
+
// ephemeral:每消息一个进程(旧行为)——spawn 一轮即回收的常驻进程,循环逻辑与常驻同一条
|
|
730
|
+
const proc = SessionProcess.spawn(key ?? `ephemeral:${Math.random().toString(36).slice(2)}`, opts, false);
|
|
731
|
+
try {
|
|
732
|
+
return await proc.sendTurn(prompt, opts, cb);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
await proc.kill();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// 常驻路径:复用三校验(活着 / 指纹一致 / 指针一致),任一不符回收重建。
|
|
739
|
+
// 内联空闲检查与 sweeper 双保险:消息到达时先行判定,不等巡检周期
|
|
740
|
+
const fp = fingerprintOf(opts);
|
|
741
|
+
const wantSession = opts.resumeSessionId ?? '';
|
|
742
|
+
let proc = pool.get(key);
|
|
743
|
+
if (proc && (!proc.dead) && !proc.isBusy && proc.fingerprint === fp && proc.sessionId === wantSession
|
|
744
|
+
&& !(proc.idleMs > 0 && Date.now() - proc.lastUsedAt > proc.idleMs)) {
|
|
745
|
+
console.log(`[executor] 常驻进程复用:${key}(会话 ${proc.sessionId || '待 init'})`);
|
|
746
|
+
return await proc.sendTurn(prompt, opts, cb);
|
|
747
|
+
}
|
|
748
|
+
if (proc) {
|
|
749
|
+
// 归因日志:指纹漂移(配置热改)/ 指针漂移(/new /resume /ws use / 群聊滚动)/ 空闲超阈值 / 忙碌兜底
|
|
750
|
+
if (proc.isBusy) {
|
|
751
|
+
console.warn(`[executor] 通道 ${key} 的常驻进程仍在忙碌(异常:FIFO 串行下不应发生),本轮独立冷启`);
|
|
752
|
+
const standalone = SessionProcess.spawn(`${key}:overlapping-${Date.now()}`, { ...opts, affinityKey: undefined, resident: false }, false);
|
|
753
|
+
try {
|
|
754
|
+
return await standalone.sendTurn(prompt, opts, cb);
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
await standalone.kill();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const reason = proc.fingerprint !== fp
|
|
761
|
+
? `配置指纹变化:${describePartDrift(proc.spawnParts, fingerprintParts(opts)).join('、')}`
|
|
762
|
+
: proc.sessionId !== wantSession ? '会话指针变化'
|
|
763
|
+
: proc.idleMs > 0 && Date.now() - proc.lastUsedAt > proc.idleMs ? '空闲超阈值'
|
|
764
|
+
: '进程已退出';
|
|
765
|
+
console.log(`[executor] 常驻进程回收重建(${reason}):${key}`);
|
|
766
|
+
await proc.kill();
|
|
767
|
+
proc = undefined;
|
|
346
768
|
}
|
|
769
|
+
const created = SessionProcess.spawn(key, opts, true);
|
|
770
|
+
register(created, opts.residentMax);
|
|
771
|
+
return await created.sendTurn(prompt, opts, cb);
|
|
347
772
|
}
|
|
348
773
|
//# sourceMappingURL=claude-executor.js.map
|