lark-remote 0.1.6 → 0.1.7
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/CHANGELOG.md +23 -1
- package/README.en.md +9 -1
- package/README.md +14 -2
- package/dist/bridge/approval-coordinator.d.ts +57 -34
- package/dist/bridge/approval-coordinator.js +191 -40
- package/dist/bridge/inbound-media.d.ts +62 -0
- package/dist/bridge/inbound-media.js +282 -0
- package/dist/bridge/index.d.ts +78 -7
- package/dist/bridge/index.js +202 -79
- package/dist/bridge/queue-manager.d.ts +2 -1
- package/dist/bridge/queue-manager.js +1 -0
- package/dist/card/approval-render.js +152 -36
- package/dist/card/bash-card-session.d.ts +2 -24
- package/dist/card/bash-card-session.js +6 -73
- package/dist/card/bash-renderer.js +2 -3
- package/dist/card/card-budget.js +29 -75
- package/dist/card/card-session.d.ts +48 -0
- package/dist/card/card-session.js +86 -0
- package/dist/card/card-shared.d.ts +1 -12
- package/dist/card/card-shared.js +1 -26
- package/dist/card/run-card-session.d.ts +2 -55
- package/dist/card/run-card-session.js +6 -104
- package/dist/card/run-renderer.js +99 -232
- package/dist/card/run-state.d.ts +13 -0
- package/dist/card/run-state.js +25 -16
- package/dist/card/text-truncate.d.ts +13 -12
- package/dist/card/text-truncate.js +13 -63
- package/dist/common/fs.d.ts +8 -0
- package/dist/common/fs.js +17 -0
- package/dist/common/guards.d.ts +0 -20
- package/dist/common/guards.js +0 -43
- package/dist/common/sleep.d.ts +2 -0
- package/dist/common/sleep.js +5 -0
- package/dist/common/truncate.d.ts +39 -0
- package/dist/common/truncate.js +92 -0
- package/dist/config/codex-config.d.ts +1 -1
- package/dist/config/codex-config.js +1 -1
- package/dist/config/index.d.ts +88 -23
- package/dist/config/index.js +105 -34
- package/dist/connector/index.d.ts +69 -2
- package/dist/connector/index.js +107 -1
- package/dist/index.js +123 -105
- package/dist/instance-lock.js +4 -12
- package/dist/order/alias-resolve.d.ts +13 -0
- package/dist/order/alias-resolve.js +22 -0
- package/dist/order/alias-store.d.ts +38 -0
- package/dist/order/alias-store.js +106 -0
- package/dist/persistence/atomic-write.d.ts +8 -2
- package/dist/persistence/atomic-write.js +23 -7
- package/dist/restart.js +2 -1
- package/dist/router/card-helpers.d.ts +63 -0
- package/dist/router/card-helpers.js +146 -0
- package/dist/router/config/claude.js +25 -1
- package/dist/router/config/codex.js +29 -50
- package/dist/router/config/common/model-patch.d.ts +22 -0
- package/dist/router/config/common/model-patch.js +26 -0
- package/dist/router/config/common/render.js +5 -4
- package/dist/router/config/index.d.ts +12 -0
- package/dist/router/config/index.js +25 -1
- package/dist/router/config/kimi.d.ts +4 -1
- package/dist/router/config/kimi.js +27 -0
- package/dist/router/config/opencode.js +18 -5
- package/dist/router/config/pi.js +4 -5
- package/dist/router/config/types.d.ts +11 -2
- package/dist/router/index.d.ts +56 -21
- package/dist/router/index.js +373 -548
- package/dist/router/utils.d.ts +2 -0
- package/dist/router/utils.js +4 -0
- package/dist/runner/agent-choices-common.d.ts +16 -0
- package/dist/runner/agent-choices-common.js +31 -0
- package/dist/runner/claude/runner.d.ts +47 -10
- package/dist/runner/claude/runner.js +117 -46
- package/dist/runner/claude/session.d.ts +166 -0
- package/dist/runner/claude/session.js +749 -0
- package/dist/runner/codex/app-server/protocol-types.d.ts +31 -39
- package/dist/runner/codex/app-server/protocol-types.js +1 -12
- package/dist/runner/codex/app-server/runner.d.ts +42 -59
- package/dist/runner/codex/app-server/runner.js +160 -337
- package/dist/runner/codex/app-server/translator.d.ts +1 -21
- package/dist/runner/codex/app-server/translator.js +0 -18
- package/dist/runner/codex/index.d.ts +0 -2
- package/dist/runner/codex/index.js +0 -2
- package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
- package/dist/runner/common/acp/protocol-helpers.js +21 -0
- package/dist/runner/common/acp/protocol-types.d.ts +238 -0
- package/dist/runner/common/acp/protocol-types.js +75 -0
- package/dist/runner/common/connection-based-runner.d.ts +111 -0
- package/dist/runner/common/connection-based-runner.js +261 -0
- package/dist/runner/{codex/app-server → common/jsonrpc}/client.d.ts +14 -7
- package/dist/runner/{codex/app-server → common/jsonrpc}/client.js +27 -18
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
- package/dist/runner/{codex/app-server → common/jsonrpc}/connection-manager.js +63 -28
- package/dist/runner/{codex/app-server → common/jsonrpc}/transport.d.ts +5 -4
- package/dist/runner/{codex/app-server → common/jsonrpc}/transport.js +18 -19
- package/dist/runner/common/jsonrpc/types.d.ts +32 -0
- package/dist/runner/common/jsonrpc/types.js +9 -0
- package/dist/runner/common/process-stopper.d.ts +2 -1
- package/dist/runner/common/process-stopper.js +1 -0
- package/dist/runner/common/runner-utils.d.ts +6 -13
- package/dist/runner/common/runner-utils.js +6 -47
- package/dist/runner/common/spawning-runner.d.ts +24 -70
- package/dist/runner/common/spawning-runner.js +98 -152
- package/dist/runner/index.d.ts +4 -4
- package/dist/runner/index.js +4 -4
- package/dist/runner/kimi/acp/runner.d.ts +106 -0
- package/dist/runner/kimi/acp/runner.js +448 -0
- package/dist/runner/kimi/acp/translator.d.ts +124 -0
- package/dist/runner/kimi/acp/translator.js +336 -0
- package/dist/runner/kimi/index.d.ts +1 -1
- package/dist/runner/kimi/index.js +1 -1
- package/dist/runner/opencode/acp/index.d.ts +1 -0
- package/dist/runner/opencode/acp/index.js +2 -0
- package/dist/runner/opencode/acp/runner.d.ts +121 -0
- package/dist/runner/opencode/acp/runner.js +429 -0
- package/dist/runner/opencode/acp/translator.d.ts +119 -0
- package/dist/runner/opencode/acp/translator.js +345 -0
- package/dist/runner/opencode/index.d.ts +1 -1
- package/dist/runner/opencode/index.js +1 -1
- package/dist/runner/pi/index.d.ts +1 -1
- package/dist/runner/pi/index.js +1 -1
- package/dist/runner/pi/rpc/client.d.ts +52 -0
- package/dist/runner/pi/rpc/client.js +114 -0
- package/dist/runner/pi/rpc/index.d.ts +4 -0
- package/dist/runner/pi/rpc/index.js +4 -0
- package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
- package/dist/runner/pi/rpc/protocol-types.js +21 -0
- package/dist/runner/pi/rpc/runner.d.ts +80 -0
- package/dist/runner/pi/rpc/runner.js +199 -0
- package/dist/runner/pi/rpc/translator.d.ts +55 -0
- package/dist/runner/pi/rpc/translator.js +258 -0
- package/dist/runner/registry.d.ts +7 -8
- package/dist/runner/registry.js +3 -4
- package/dist/runner/resolve-agent-choices.js +18 -59
- package/dist/runner/sync-agent-choices.js +16 -67
- package/dist/runner/types.d.ts +43 -13
- package/dist/session/claude/session-index.js +2 -3
- package/dist/session/claude/session-reader.js +3 -6
- package/dist/session/claude/sessions.js +6 -13
- package/dist/session/codex/rollout-reader.d.ts +2 -2
- package/dist/session/codex/rollout-reader.js +14 -17
- package/dist/session/codex/sessions.d.ts +1 -1
- package/dist/session/codex/sessions.js +2 -2
- package/dist/session/common/content-blocks.d.ts +7 -0
- package/dist/session/common/content-blocks.js +13 -9
- package/dist/session/common/jsonl.d.ts +8 -25
- package/dist/session/common/jsonl.js +126 -261
- package/dist/session/common/pagination.d.ts +28 -0
- package/dist/session/common/pagination.js +33 -0
- package/dist/session/kimi/sessions.d.ts +34 -1
- package/dist/session/kimi/sessions.js +208 -98
- package/dist/session/opencode/sessions.d.ts +17 -3
- package/dist/session/opencode/sessions.js +29 -29
- package/dist/session/pi/sessions.d.ts +5 -1
- package/dist/session/pi/sessions.js +12 -16
- package/dist/session/session-store.d.ts +11 -1
- package/dist/session/session-store.js +75 -178
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/install.d.ts +20 -2
- package/dist/update/install.js +51 -7
- package/package.json +1 -1
- package/dist/runner/codex/app-server/connection-manager.d.ts +0 -56
- package/dist/runner/codex/argv.d.ts +0 -33
- package/dist/runner/codex/argv.js +0 -40
- package/dist/runner/codex/factory.d.ts +0 -29
- package/dist/runner/codex/factory.js +0 -39
- package/dist/runner/codex/jsonl.d.ts +0 -37
- package/dist/runner/codex/jsonl.js +0 -221
- package/dist/runner/codex/runner.d.ts +0 -66
- package/dist/runner/codex/runner.js +0 -129
- package/dist/runner/common/exec-translator.d.ts +0 -73
- package/dist/runner/common/exec-translator.js +0 -105
- package/dist/runner/kimi/jsonl.d.ts +0 -28
- package/dist/runner/kimi/jsonl.js +0 -143
- package/dist/runner/kimi/runner.d.ts +0 -49
- package/dist/runner/kimi/runner.js +0 -105
- package/dist/runner/opencode/argv.d.ts +0 -32
- package/dist/runner/opencode/argv.js +0 -35
- package/dist/runner/opencode/jsonl.d.ts +0 -68
- package/dist/runner/opencode/jsonl.js +0 -295
- package/dist/runner/opencode/runner.d.ts +0 -61
- package/dist/runner/opencode/runner.js +0 -93
- package/dist/runner/pi/jsonl.d.ts +0 -128
- package/dist/runner/pi/jsonl.js +0 -249
- package/dist/runner/pi/runner.d.ts +0 -36
- package/dist/runner/pi/runner.js +0 -76
- package/dist/session/common/catch-up.d.ts +0 -12
- package/dist/session/common/catch-up.js +0 -31
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeSession: 长驻交互式 Claude Code 会话(stream-json 双向通道)。
|
|
3
|
+
*
|
|
4
|
+
* 协议(本机 claude 2.1.233 真实 spike 验证):
|
|
5
|
+
* - spawn 参数:
|
|
6
|
+
* --output-format stream-json --input-format stream-json
|
|
7
|
+
* --permission-prompt-tool stdio --replay-user-messages [--verbose]
|
|
8
|
+
* [--permission-mode <mode>] [--resume <sessionId>]
|
|
9
|
+
* - 用户消息(stdin):
|
|
10
|
+
* {"type":"user","message":{"role":"user","content":"..."}}
|
|
11
|
+
* - 压缩(stdin):content 为 "/compact" 时 CLI 本地拦截为 slash 命令(不会
|
|
12
|
+
* 作为模型提示词),压缩 turn 以普通 result 收尾(真实 claude 2.1.233 实测)。
|
|
13
|
+
* - 权限请求(stdout):
|
|
14
|
+
* {"type":"control_request","request_id":"...","request":{
|
|
15
|
+
* "subtype":"can_use_tool","tool_name":"Bash","input":{...}}}
|
|
16
|
+
* - 审批响应(stdin):
|
|
17
|
+
* {"type":"control_response","response":{"subtype":"success",
|
|
18
|
+
* "request_id":"...","response":{"behavior":"allow","updatedInput":{...}}}}
|
|
19
|
+
* deny 时 response={"behavior":"deny","message":"..."}
|
|
20
|
+
* - AskUserQuestion 走同一通道(tool_name="AskUserQuestion"),选项在
|
|
21
|
+
* input.questions[];答案回填 input.answers 后 allow。
|
|
22
|
+
* - result 事件 subtype 为 compact/compaction 是 turn 中途压缩,不是 turn 结束;
|
|
23
|
+
* 其余 subtype(success/error/...)才是 turn 终态。
|
|
24
|
+
*
|
|
25
|
+
* 生命周期:一个 workspace 一个长驻进程(lifetime='workspace')。每次
|
|
26
|
+
* run(message) = 写一条 user 消息 + 消费 stdout 事件直到本 turn 的 result;
|
|
27
|
+
* 进程在 turn 之间保持存活(stdin 不关闭),/stop / /new / /cd / 看门狗超时
|
|
28
|
+
* 时经 ProcessStopper 组杀,下条消息按 SessionStore 的 sessionId --resume。
|
|
29
|
+
*/
|
|
30
|
+
import { silentlyUnlink } from '../../common/fs.js';
|
|
31
|
+
import { getLogger } from '../../logger/index.js';
|
|
32
|
+
import { SpawningRunner } from '../common/spawning-runner.js';
|
|
33
|
+
import { pipeAllStdio } from '../common/runner-utils.js';
|
|
34
|
+
import { authErrorEvent, syntheticInitEvent } from '../common/runner-utils.js';
|
|
35
|
+
/** 拒绝权限时的默认提示(进入 claude 上下文,中文用户可读)。 */
|
|
36
|
+
const DENY_MESSAGE = '用户拒绝了此工具调用,请停止并等待用户指令。';
|
|
37
|
+
/** 会话级空闲回收默认 TTL(对齐 codex appServer.idleTtlMs 默认 30 分钟)。 */
|
|
38
|
+
const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000;
|
|
39
|
+
/** result 事件里表示「turn 中途压缩」的 subtype(不是 turn 结束)。 */
|
|
40
|
+
const COMPACTION_RESULT_SUBTYPES = new Set(['compact', 'compaction']);
|
|
41
|
+
/**
|
|
42
|
+
* ClaudeSession extends SpawningRunner 复用其 spawn/pid 文件/killOrphan/
|
|
43
|
+
* ProcessStopper/SpawnHeartbeat/退出分发器机制(P1-1/P1-10/P1-11 契约保持),
|
|
44
|
+
* 但 run() 覆盖为「长驻 + 按 turn 消费」:进程跨 turn 存活,turn 结束以
|
|
45
|
+
* stream-json result 事件(非 compact)为界,而不是进程退出。
|
|
46
|
+
*/
|
|
47
|
+
export class ClaudeSession extends SpawningRunner {
|
|
48
|
+
permissionMode;
|
|
49
|
+
settings;
|
|
50
|
+
defaultModel;
|
|
51
|
+
defaultEffort;
|
|
52
|
+
idleTtlMs;
|
|
53
|
+
/** 当前 turn 是否在途(防止并发 run 写乱 stdin)。 */
|
|
54
|
+
turnActive = false;
|
|
55
|
+
/** 本进程是否已收到 system/init(--resume 会先重放上一轮旧 result)。 */
|
|
56
|
+
sawInit = false;
|
|
57
|
+
/** stdout 流是否已结束(进程退出/崩溃)。 */
|
|
58
|
+
streamEnded = false;
|
|
59
|
+
/** 最近一次 spawn 的工作目录(control_request 视图用)。 */
|
|
60
|
+
cwd = '';
|
|
61
|
+
/** 最近一次 init 报告的 session_id(错误结果与视图摘要用)。 */
|
|
62
|
+
sessionId = '';
|
|
63
|
+
/** 已翻译事件队列(turn 消费循环读取;turn 之间清空)。 */
|
|
64
|
+
eventQueue = [];
|
|
65
|
+
waitResolve = null;
|
|
66
|
+
/** 待审批的 control_request 原始 input(回写 updatedInput 需要)。 */
|
|
67
|
+
pendingToolInputs = new Map();
|
|
68
|
+
/** 允许所有:后续 control_request 自动 allow(本会话进程生命周期内有效)。 */
|
|
69
|
+
autoApprove = false;
|
|
70
|
+
/** 串行化 stdin 写入(用户消息与审批响应交错到达)。 */
|
|
71
|
+
stdinQueue = Promise.resolve();
|
|
72
|
+
startPromise = null;
|
|
73
|
+
/**
|
|
74
|
+
* 进程代际 token:stop()/dispose() 会先把 currentProcess 置 null,流 reader
|
|
75
|
+
* 的 finally 不能再用 `currentProcess === proc` 判断归属(会漏掉 streamEnded
|
|
76
|
+
* 唤醒,turn 循环永久挂起)。每次 spawn 递增,旧代际 reader 的残留事件与
|
|
77
|
+
* 收尾一律跳过。
|
|
78
|
+
*/
|
|
79
|
+
processGeneration = 0;
|
|
80
|
+
idleTimer = null;
|
|
81
|
+
/** 进程退出登记('close' 事件后 exitCode 才可靠;流结束错误路径 await 它)。 */
|
|
82
|
+
processExitPromise = Promise.resolve({ code: null, signal: null });
|
|
83
|
+
constructor(opts) {
|
|
84
|
+
super({
|
|
85
|
+
pidDir: opts.pidDir,
|
|
86
|
+
workspace: opts.workspace,
|
|
87
|
+
stopGraceMs: opts.stopGraceMs,
|
|
88
|
+
spawnHeartbeatMs: opts.spawnHeartbeatMs,
|
|
89
|
+
pidFilePrefix: 'claude',
|
|
90
|
+
logTag: 'claude-runner',
|
|
91
|
+
});
|
|
92
|
+
this.binary = 'claude';
|
|
93
|
+
this.permissionMode = opts.permissionMode ?? 'bypassPermissions';
|
|
94
|
+
this.settings = opts.settings;
|
|
95
|
+
this.defaultModel = opts.model;
|
|
96
|
+
this.defaultEffort = opts.effort;
|
|
97
|
+
this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
98
|
+
}
|
|
99
|
+
get pid() {
|
|
100
|
+
return this.currentProcess?.pid;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 每 turn 入口:确保进程已 spawn(可带 --resume)→ 写用户消息 → 消费事件
|
|
104
|
+
* 直到本 turn 的 result。进程在 turn 结束后保持存活,供下一条消息复用。
|
|
105
|
+
*/
|
|
106
|
+
async *run(message, opts) {
|
|
107
|
+
if (this.turnActive) {
|
|
108
|
+
getLogger().warn(`[${this.logTag}] run() called while a turn is active, refusing`);
|
|
109
|
+
throw new Error('claude process already running');
|
|
110
|
+
}
|
|
111
|
+
// turnActive 必须在 startProcess 之前置位:进程 spawn 后 isRunning 即可见,
|
|
112
|
+
// 若等 startProcess 完成才置位,第二个 run() 会在竞态窗口误入(线上由
|
|
113
|
+
// bridge 串行队列保护,测试直接并发调用会踩到)。
|
|
114
|
+
this.turnActive = true;
|
|
115
|
+
try {
|
|
116
|
+
// /new(sessionId 清空)或 /resume 切换到别的会话:请求的会话与当前
|
|
117
|
+
// 长驻进程实际所在的会话不一致 → 回收旧进程,按请求重新 spawn(fresh
|
|
118
|
+
// 或 --resume 目标会话)。比较进程当前 session(init 报告值)而非 spawn
|
|
119
|
+
// 请求值:bridge 会把 init 的 session_id 写回 SessionStore,第二条消息
|
|
120
|
+
// 带着它来;若拿 spawn 请求值('')比较,每条消息都会误判切换、杀进程
|
|
121
|
+
// 重启,长驻设计失效(2026-08-16 review P0)。
|
|
122
|
+
if (this.isRunning && this.sessionId !== (opts.sessionId ?? '')) {
|
|
123
|
+
getLogger().info(`[${this.logTag}] session mismatch: process=${this.sessionId || '(fresh)'} ` +
|
|
124
|
+
`requested=${opts.sessionId ?? '(fresh)'}, recycling process`);
|
|
125
|
+
await this.stop();
|
|
126
|
+
}
|
|
127
|
+
this.stoppedByUser = false;
|
|
128
|
+
const spawnError = await this.startProcess(opts);
|
|
129
|
+
if (spawnError) {
|
|
130
|
+
// §9.22 守卫:错误 result 前必须补 synthetic init,否则 bridge 的
|
|
131
|
+
// pre-init result guard 与 run-state reducer 会静默丢弃错误信息。
|
|
132
|
+
yield syntheticInitEvent(opts.sessionId);
|
|
133
|
+
yield authErrorEvent(spawnError);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// turn 之间的 idle 噪音(如 prompt_suggestion)不属于本 turn,先清空。
|
|
137
|
+
this.eventQueue.length = 0;
|
|
138
|
+
try {
|
|
139
|
+
await this.writeUserMessage(message);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
// review:stop()/进程死亡与写 stdin 竞态(EPIPE/ENOTCONN)。用户
|
|
143
|
+
// stop 已置 stoppedByUser(或进程已死/流已结束)时,写入失败不抛给
|
|
144
|
+
// 调用方,交由 consumeTurn 按 interrupted/error 产出终态——否则
|
|
145
|
+
// stop 在途的 run 会以裸 EPIPE 崩溃(2026-08-16 复现的 flaky)。
|
|
146
|
+
if (this.stoppedByUser || !this.isRunning || this.streamEnded) {
|
|
147
|
+
getLogger().warn(`[${this.logTag}] user message write failed, process stopping: ${err instanceof Error ? err.message : String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
yield* this.consumeTurn();
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this.turnActive = false;
|
|
157
|
+
this.armIdleTimer();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 写一条用户消息到 stdin(--input-format stream-json 的 user 事件)。
|
|
162
|
+
*/
|
|
163
|
+
async sendUserMessage(message) {
|
|
164
|
+
await this.writeUserMessage(message);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 回写审批响应(允许/拒绝)。
|
|
168
|
+
*/
|
|
169
|
+
async respondPermission(requestId, result) {
|
|
170
|
+
if (!this.isRunning) {
|
|
171
|
+
getLogger().warn(`[${this.logTag}] respondPermission skipped: process not running requestId=${requestId}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// review P2-2:requestId 不在待审批表(已 control_cancel / 进程回收)时
|
|
175
|
+
// 丢弃响应,不能以空 updatedInput allow(claude 会拿空输入执行工具)。
|
|
176
|
+
if (!this.pendingToolInputs.has(requestId)) {
|
|
177
|
+
getLogger().warn(`[${this.logTag}] respondPermission dropped: unknown requestId=${requestId}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const input = this.pendingToolInputs.get(requestId);
|
|
181
|
+
if (result.behavior === 'allow') {
|
|
182
|
+
await this.writeControlResponse(requestId, {
|
|
183
|
+
behavior: 'allow',
|
|
184
|
+
updatedInput: input ?? {},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
await this.writeControlResponse(requestId, {
|
|
189
|
+
behavior: 'deny',
|
|
190
|
+
message: result.message ?? DENY_MESSAGE,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
this.pendingToolInputs.delete(requestId);
|
|
194
|
+
getLogger().info(`[${this.logTag}] permission response requestId=${requestId} behavior=${result.behavior}`);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 回写 AskUserQuestion 答案:updatedInput = 原 input + answers。
|
|
198
|
+
*/
|
|
199
|
+
async respondAskUserQuestion(requestId, answers) {
|
|
200
|
+
if (!this.isRunning)
|
|
201
|
+
return;
|
|
202
|
+
// review P2-2:未知 requestId 时不能回写缺原始 questions 的 answers
|
|
203
|
+
// (官方要求 updatedInput 必须包含原始 questions)。
|
|
204
|
+
if (!this.pendingToolInputs.has(requestId)) {
|
|
205
|
+
getLogger().warn(`[${this.logTag}] respondAskUserQuestion dropped: unknown requestId=${requestId}`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const input = (this.pendingToolInputs.get(requestId) ?? {});
|
|
209
|
+
await this.writeControlResponse(requestId, {
|
|
210
|
+
behavior: 'allow',
|
|
211
|
+
updatedInput: { ...input, answers },
|
|
212
|
+
});
|
|
213
|
+
this.pendingToolInputs.delete(requestId);
|
|
214
|
+
getLogger().info(`[${this.logTag}] AskUserQuestion response requestId=${requestId} questions=${Object.keys(answers).length}`);
|
|
215
|
+
}
|
|
216
|
+
/** 允许所有:后续 control_request 自动 allow(本会话进程生命周期内有效)。 */
|
|
217
|
+
setAutoApprove(value) {
|
|
218
|
+
this.autoApprove = value;
|
|
219
|
+
getLogger().info(`[${this.logTag}] autoApprove=${value}`);
|
|
220
|
+
}
|
|
221
|
+
/** 释放会话:停止进程(优雅 SIGTERM→SIGKILL)。 */
|
|
222
|
+
async dispose() {
|
|
223
|
+
getLogger().info(`[${this.logTag}] dispose session`);
|
|
224
|
+
await this.stop();
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 停止会话(覆盖基类以先清空闲回收 timer)。
|
|
228
|
+
*/
|
|
229
|
+
async stop(opts) {
|
|
230
|
+
this.clearIdleTimer();
|
|
231
|
+
await super.stop(opts);
|
|
232
|
+
}
|
|
233
|
+
// =========================================================================
|
|
234
|
+
// SpawningRunner hooks
|
|
235
|
+
// =========================================================================
|
|
236
|
+
getStdio() {
|
|
237
|
+
return pipeAllStdio();
|
|
238
|
+
}
|
|
239
|
+
buildArgv(opts) {
|
|
240
|
+
const args = [
|
|
241
|
+
'--output-format',
|
|
242
|
+
'stream-json',
|
|
243
|
+
'--input-format',
|
|
244
|
+
'stream-json',
|
|
245
|
+
'--permission-prompt-tool',
|
|
246
|
+
'stdio',
|
|
247
|
+
'--replay-user-messages',
|
|
248
|
+
'--verbose',
|
|
249
|
+
];
|
|
250
|
+
// 'default' = Claude 的未设置模式(settings.json 官方值),等价于不传
|
|
251
|
+
// --permission-mode(交互式默认:高风险工具逐个询问)。
|
|
252
|
+
// 注:claude 拒绝 root + bypassPermissions(root 下需降级为 auto);
|
|
253
|
+
// 本项目是 macOS 单用户场景不做该降级。
|
|
254
|
+
if (this.permissionMode && this.permissionMode !== 'default') {
|
|
255
|
+
args.push('--permission-mode', this.permissionMode);
|
|
256
|
+
}
|
|
257
|
+
if (opts.sessionId) {
|
|
258
|
+
args.push('--resume', opts.sessionId);
|
|
259
|
+
}
|
|
260
|
+
if (opts.model) {
|
|
261
|
+
args.push('--model', opts.model);
|
|
262
|
+
}
|
|
263
|
+
const effort = opts.effort ?? this.defaultEffort;
|
|
264
|
+
if (effort) {
|
|
265
|
+
args.push('--effort', effort);
|
|
266
|
+
}
|
|
267
|
+
const settings = this.settings;
|
|
268
|
+
if (settings) {
|
|
269
|
+
args.push('--settings', settings);
|
|
270
|
+
}
|
|
271
|
+
return args;
|
|
272
|
+
}
|
|
273
|
+
// =========================================================================
|
|
274
|
+
// Internal: process lifecycle
|
|
275
|
+
// =========================================================================
|
|
276
|
+
/**
|
|
277
|
+
* 确保长驻进程已 spawn(首次或上次被停止后)。串行化并发调用。
|
|
278
|
+
* 返回 null = 成功;返回 string = spawn 失败的错误消息(调用方 yield)。
|
|
279
|
+
*/
|
|
280
|
+
async startProcess(opts) {
|
|
281
|
+
if (this.isRunning) {
|
|
282
|
+
// review P3-4:复用进程(跨 turn 长驻)时清掉上一轮的空闲回收 timer——
|
|
283
|
+
// 否则长 turn 中它会空转触发一次(回调因 turnActive 空转返回,无害但
|
|
284
|
+
// 多余);新一轮结束后的 armIdleTimer 会重新武装。
|
|
285
|
+
this.clearIdleTimer();
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
if (this.startPromise)
|
|
289
|
+
return this.startPromise;
|
|
290
|
+
this.startPromise = this.doStartProcess(opts).finally(() => {
|
|
291
|
+
this.startPromise = null;
|
|
292
|
+
});
|
|
293
|
+
return this.startPromise;
|
|
294
|
+
}
|
|
295
|
+
async doStartProcess(opts) {
|
|
296
|
+
this.clearIdleTimer();
|
|
297
|
+
this.sawInit = false;
|
|
298
|
+
this.streamEnded = false;
|
|
299
|
+
this.sessionId = opts.sessionId ?? '';
|
|
300
|
+
this.cwd = opts.cwd;
|
|
301
|
+
this.autoApprove = false;
|
|
302
|
+
this.pendingToolInputs.clear();
|
|
303
|
+
let proc;
|
|
304
|
+
try {
|
|
305
|
+
proc = await this.spawnChild(opts);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
this.currentProcess = null;
|
|
309
|
+
return err.message;
|
|
310
|
+
}
|
|
311
|
+
const generation = ++this.processGeneration;
|
|
312
|
+
// 永久 no-op error 兜底:写成功后 once 监听已移除,若子进程随后退出导致
|
|
313
|
+
// stdin 管道 EPIPE,无监听器的 'error' 事件会把进程炸成 unhandled。
|
|
314
|
+
// 写入失败由 writeStdin 的 once 监听 + write 回调收敛。
|
|
315
|
+
proc.stdin?.on('error', () => {
|
|
316
|
+
/* EPIPE 兜底:写路径已收敛,这里只防 EventEmitter 默认抛错 */
|
|
317
|
+
});
|
|
318
|
+
// 进程退出登记(组杀后 close 事件触发;供流结束错误路径读取 code/signal)。
|
|
319
|
+
// Node 在 stdout 'close' 后一个 tick 才落 exitCode,直接读 proc.exitCode
|
|
320
|
+
// 会竞态拿到 null;必须等 proc 'close' 事件。
|
|
321
|
+
this.processExitPromise = new Promise((resolve) => {
|
|
322
|
+
proc.once('close', (code, signal) => {
|
|
323
|
+
this.spawnHeartbeat.clear();
|
|
324
|
+
this.exitCode = code;
|
|
325
|
+
this.exitSignal = signal;
|
|
326
|
+
if (code !== null && code !== 0 && !this.stoppedByUser) {
|
|
327
|
+
getLogger().error(`[${this.logTag}] non-zero exit code=${code} signal=${signal} stderr=${this.spawnStderr.slice(-500)}`);
|
|
328
|
+
}
|
|
329
|
+
resolve({ code, signal });
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
this.startStreamReader(proc, generation);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* 后台消费 stdout JSONL → 翻译 → 入队。进程退出时清理 pid 文件并标记
|
|
337
|
+
* streamEnded 唤醒等待中的 turn 循环。
|
|
338
|
+
*/
|
|
339
|
+
startStreamReader(proc, generation) {
|
|
340
|
+
const stream = this.createStreamReader(proc.stdout);
|
|
341
|
+
void (async () => {
|
|
342
|
+
try {
|
|
343
|
+
for await (const raw of stream) {
|
|
344
|
+
if (generation !== this.processGeneration)
|
|
345
|
+
return; // 旧进程残留事件丢弃
|
|
346
|
+
const events = this.translateEvent(raw);
|
|
347
|
+
if (events.length > 0) {
|
|
348
|
+
this.eventQueue.push(...events);
|
|
349
|
+
this.wakeWaiters();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
getLogger().error(`[${this.logTag}] stream error: ${err}`);
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
if (generation === this.processGeneration) {
|
|
358
|
+
this.streamEnded = true;
|
|
359
|
+
this.currentProcess = null;
|
|
360
|
+
silentlyUnlink(this.pidFilePath);
|
|
361
|
+
this.wakeWaiters();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
365
|
+
}
|
|
366
|
+
/** 最近一次进程退出的 code/signal(流结束错误结果构造用)。 */
|
|
367
|
+
exitCode = null;
|
|
368
|
+
exitSignal = null;
|
|
369
|
+
// =========================================================================
|
|
370
|
+
// Internal: turn consumption
|
|
371
|
+
// =========================================================================
|
|
372
|
+
/**
|
|
373
|
+
* 消费事件队列直到本 turn 的 result 事件(非 compact/compaction)。
|
|
374
|
+
* 消费者提前关闭生成器(bridge 循环体抛错)→ 杀进程防孤儿(P1-11 语义)。
|
|
375
|
+
*
|
|
376
|
+
* result = turn 终态(2026-08-16 真实 claude 实测:长驻模式下 result 之后
|
|
377
|
+
* 不再有 stdout 事件,后台任务完成是静默的;旧 -p 流程「等进程退出再收尾」
|
|
378
|
+
* 的 post-result drain 在长驻模式下无对应物)。
|
|
379
|
+
*/
|
|
380
|
+
async *consumeTurn() {
|
|
381
|
+
let active = true;
|
|
382
|
+
try {
|
|
383
|
+
while (true) {
|
|
384
|
+
while (this.eventQueue.length > 0) {
|
|
385
|
+
const ev = this.eventQueue.shift();
|
|
386
|
+
yield ev;
|
|
387
|
+
if (isTurnResultEvent(ev)) {
|
|
388
|
+
active = false;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (this.streamEnded) {
|
|
393
|
+
active = false;
|
|
394
|
+
yield await this.buildStreamEndedError();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
await new Promise((resolve) => {
|
|
398
|
+
this.waitResolve = resolve;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
if (active) {
|
|
404
|
+
try {
|
|
405
|
+
await this.stop({ immediate: true });
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* ignore */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** 构造进程退出且无终态 result 时的统一错误结果(对齐 SpawningRunner 语义)。 */
|
|
414
|
+
async buildStreamEndedError() {
|
|
415
|
+
// 进程已死但 proc 'close' 事件可能略晚于 stdout 关闭:等退出登记拿可靠
|
|
416
|
+
// code/signal。stdout 关闭 = 进程退出(claude 独占 stdout),'close' 必然
|
|
417
|
+
// 随后触发,不会挂死 turn 循环。
|
|
418
|
+
const exited = await this.processExitPromise;
|
|
419
|
+
const stderrTail = this.spawnStderr ? this.spawnStderr.slice(-500) : '';
|
|
420
|
+
let errorMessage;
|
|
421
|
+
if (this.stoppedByUser) {
|
|
422
|
+
errorMessage = `${this.binary} interrupted by user`;
|
|
423
|
+
}
|
|
424
|
+
else if (exited.signal !== null) {
|
|
425
|
+
errorMessage = `${this.binary} killed by signal ${exited.signal}${stderrTail ? `: ${stderrTail}` : ''}`;
|
|
426
|
+
}
|
|
427
|
+
else if (exited.code !== null && exited.code !== 0) {
|
|
428
|
+
errorMessage = `${this.binary} exited code=${exited.code}${stderrTail ? `: ${stderrTail}` : ''}`;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
errorMessage = `${this.binary} 输出流已结束,但未收到 result 事件${stderrTail ? `: ${stderrTail}` : ''}`;
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
type: 'result',
|
|
435
|
+
subtype: this.stoppedByUser ? 'interrupted' : 'error',
|
|
436
|
+
session_id: this.sessionId,
|
|
437
|
+
errorMessage,
|
|
438
|
+
timestamp: new Date().toISOString(),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
wakeWaiters() {
|
|
442
|
+
if (this.waitResolve) {
|
|
443
|
+
const resolve = this.waitResolve;
|
|
444
|
+
this.waitResolve = null;
|
|
445
|
+
resolve();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* 会话级空闲回收:turn 结束后 idleTtlMs 无新消息则停止进程(对齐 codex
|
|
450
|
+
* ConnectionManager 的 idle TTL;/cd 遗留的旧 cwd runner 靠它回收)。
|
|
451
|
+
* turn 在途时永不触发;0 表示禁用。
|
|
452
|
+
*/
|
|
453
|
+
armIdleTimer() {
|
|
454
|
+
this.clearIdleTimer();
|
|
455
|
+
if (this.idleTtlMs <= 0)
|
|
456
|
+
return;
|
|
457
|
+
this.idleTimer = setTimeout(() => {
|
|
458
|
+
this.idleTimer = null;
|
|
459
|
+
if (this.turnActive || !this.isRunning)
|
|
460
|
+
return;
|
|
461
|
+
getLogger().info(`[${this.logTag}] idle timeout ${this.idleTtlMs}ms, stopping session workspace=${this.cwd}`);
|
|
462
|
+
void this.stop().catch((err) => {
|
|
463
|
+
getLogger().warn(`[${this.logTag}] idle stop failed: ${err.message}`);
|
|
464
|
+
});
|
|
465
|
+
}, this.idleTtlMs);
|
|
466
|
+
}
|
|
467
|
+
clearIdleTimer() {
|
|
468
|
+
if (this.idleTimer) {
|
|
469
|
+
clearTimeout(this.idleTimer);
|
|
470
|
+
this.idleTimer = null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// =========================================================================
|
|
474
|
+
// Internal: stdin writes
|
|
475
|
+
// =========================================================================
|
|
476
|
+
async writeUserMessage(message) {
|
|
477
|
+
await this.writeStdin({
|
|
478
|
+
type: 'user',
|
|
479
|
+
message: { role: 'user', content: message },
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async writeControlResponse(requestId, response) {
|
|
483
|
+
await this.writeStdin({
|
|
484
|
+
type: 'control_response',
|
|
485
|
+
response: { subtype: 'success', request_id: requestId, response },
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* 串行化 stdin 写入(互斥)。进程未运行/管道已毁 → 抛错(调用方按需忽略)。
|
|
490
|
+
*/
|
|
491
|
+
writeStdin(obj) {
|
|
492
|
+
const p = this.stdinQueue.then(async () => {
|
|
493
|
+
const proc = this.currentProcess;
|
|
494
|
+
if (!proc || proc.stdin === null || proc.stdin.destroyed) {
|
|
495
|
+
throw new Error('claude process is not running');
|
|
496
|
+
}
|
|
497
|
+
const stdin = proc.stdin;
|
|
498
|
+
const data = JSON.stringify(obj) + '\n';
|
|
499
|
+
await new Promise((resolve, reject) => {
|
|
500
|
+
// 一次性 error 监听:write 回调与 'error' 事件都会携带 EPIPE,竞态下
|
|
501
|
+
// 先到者 reject、后到者无操作;回调/事件触发后立即移除,避免监听器
|
|
502
|
+
// 跨写入残留(进程死后旧监听器把后续错误派发给已完结的 promise)。
|
|
503
|
+
const onError = (err) => {
|
|
504
|
+
stdin.removeListener('error', onError);
|
|
505
|
+
reject(err);
|
|
506
|
+
};
|
|
507
|
+
stdin.once('error', onError);
|
|
508
|
+
try {
|
|
509
|
+
stdin.write(data, (err) => {
|
|
510
|
+
stdin.removeListener('error', onError);
|
|
511
|
+
if (err)
|
|
512
|
+
reject(err);
|
|
513
|
+
else
|
|
514
|
+
resolve();
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
// 同步 throw(如 socket 已 destroy 时的 ENOTCONN):转成正常
|
|
519
|
+
// rejection,避免逃逸为 unhandled rejection。
|
|
520
|
+
stdin.removeListener('error', onError);
|
|
521
|
+
reject(err);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
// 单次写失败不阻塞后续写(进程已死时后续写会各自快速失败)。
|
|
526
|
+
this.stdinQueue = p.catch(() => { });
|
|
527
|
+
return p;
|
|
528
|
+
}
|
|
529
|
+
// =========================================================================
|
|
530
|
+
// Internal: event translation
|
|
531
|
+
// =========================================================================
|
|
532
|
+
translateEvent(raw) {
|
|
533
|
+
const event = raw;
|
|
534
|
+
const type = event.type;
|
|
535
|
+
// review P3-3:--resume 会先重放上一轮历史事件(旧 result 之外还可能有
|
|
536
|
+
// 旧 assistant/user 内容)。init 之前除 init 本身外一律丢弃,避免历史
|
|
537
|
+
// 内容混进当前卡片(result 分支的 sawInit 守卫升级为全类型守卫)。
|
|
538
|
+
if (type !== 'system' && !this.sawInit)
|
|
539
|
+
return [];
|
|
540
|
+
if (type === 'control_request') {
|
|
541
|
+
return this.translateControlRequest(event);
|
|
542
|
+
}
|
|
543
|
+
if (type === 'control_response') {
|
|
544
|
+
// claude 会把我们写入的 control_response 回显到 stdout;非新事件,丢弃。
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
if (type === 'control_cancel_request') {
|
|
548
|
+
// claude 撤销了一个待审批请求(turn 结束/不再需要答案)→ 协调器移除条目。
|
|
549
|
+
const requestId = event.request_id;
|
|
550
|
+
if (requestId !== null && requestId !== undefined) {
|
|
551
|
+
this.pendingToolInputs.delete(requestId);
|
|
552
|
+
return [
|
|
553
|
+
{
|
|
554
|
+
type: 'approval_resolved',
|
|
555
|
+
requestId,
|
|
556
|
+
outcome: 'resolved',
|
|
557
|
+
timestamp: new Date().toISOString(),
|
|
558
|
+
},
|
|
559
|
+
];
|
|
560
|
+
}
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
if (type === 'user') {
|
|
564
|
+
// --replay-user-messages 会把用户消息原样回显(content 为 string)。
|
|
565
|
+
// 该事件不能进卡片:run-state 的 reduceToolResultEvent 会把 string 按
|
|
566
|
+
// 字符迭代成伪 tool_result。真正的 tool_result 事件 content 是数组。
|
|
567
|
+
const content = event.message?.content;
|
|
568
|
+
if (!Array.isArray(content))
|
|
569
|
+
return [];
|
|
570
|
+
return [this.withTimestamp(event)];
|
|
571
|
+
}
|
|
572
|
+
if (type === 'result') {
|
|
573
|
+
// --resume 会先重放上一轮旧 result(早于 system/init):丢弃,否则
|
|
574
|
+
// consumeTurn 把历史结果误判为当前 turn 结束。bridge 有同款守卫(双保险)。
|
|
575
|
+
if (!this.sawInit)
|
|
576
|
+
return [];
|
|
577
|
+
return [this.withTimestamp(event)];
|
|
578
|
+
}
|
|
579
|
+
if (type === 'system' && event.subtype === 'init') {
|
|
580
|
+
this.sawInit = true;
|
|
581
|
+
if (typeof event.session_id === 'string' && event.session_id) {
|
|
582
|
+
this.sessionId = event.session_id;
|
|
583
|
+
}
|
|
584
|
+
return [this.withTimestamp(event)];
|
|
585
|
+
}
|
|
586
|
+
// assistant / system(其他) / 未知事件:透传(补 timestamp),未知类型由
|
|
587
|
+
// 下游 reducer 忽略,保留诊断价值。
|
|
588
|
+
return [this.withTimestamp(event)];
|
|
589
|
+
}
|
|
590
|
+
translateControlRequest(raw) {
|
|
591
|
+
const requestId = raw.request_id;
|
|
592
|
+
if (requestId === null || requestId === undefined) {
|
|
593
|
+
getLogger().warn(`[${this.logTag}] control_request missing request_id, dropping`);
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
const request = (raw.request ?? {});
|
|
597
|
+
const subtype = request.subtype;
|
|
598
|
+
if (subtype !== 'can_use_tool') {
|
|
599
|
+
// review P3-1:未知 subtype 不能静默丢弃——claude 在等 control_response,
|
|
600
|
+
// 不回应 turn 会永久挂起(无审批卡、无超时)。deny 兜底让 claude 继续。
|
|
601
|
+
getLogger().warn(`[${this.logTag}] unknown control request subtype=${String(subtype)} requestId=${requestId}`);
|
|
602
|
+
void this.writeControlResponse(requestId, {
|
|
603
|
+
behavior: 'deny',
|
|
604
|
+
message: `不支持的审批请求类型: ${String(subtype)}`,
|
|
605
|
+
}).catch((err) => {
|
|
606
|
+
getLogger().warn(`[${this.logTag}] unknown-subtype deny write failed requestId=${requestId}: ${err.message}`);
|
|
607
|
+
});
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
const toolName = String(request.tool_name ?? '');
|
|
611
|
+
const input = (request.input ?? {});
|
|
612
|
+
this.pendingToolInputs.set(requestId, input);
|
|
613
|
+
// review P1:允许所有只放行工具权限;AskUserQuestion 不能被空 answers
|
|
614
|
+
// 自动放行(claude 会把空答案当作已作答),必须继续上抛卡片让用户回答。
|
|
615
|
+
if (this.autoApprove && toolName !== 'AskUserQuestion') {
|
|
616
|
+
getLogger().info(`[${this.logTag}] auto-approving requestId=${requestId} tool=${toolName}`);
|
|
617
|
+
void this.writeControlResponse(requestId, {
|
|
618
|
+
behavior: 'allow',
|
|
619
|
+
updatedInput: input,
|
|
620
|
+
}).catch((err) => {
|
|
621
|
+
getLogger().warn(`[${this.logTag}] auto-approve write failed requestId=${requestId}: ${err.message}`);
|
|
622
|
+
});
|
|
623
|
+
// review P2-1:自动放行不经过协调器/respondPermission,条目不会随
|
|
624
|
+
// 响应被删除——立即释放,否则长会话(允许所有)下 Map 无界增长。
|
|
625
|
+
this.pendingToolInputs.delete(requestId);
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
const now = new Date().toISOString();
|
|
629
|
+
if (toolName === 'AskUserQuestion') {
|
|
630
|
+
const questions = parseUserQuestions(input);
|
|
631
|
+
if (questions.length === 0) {
|
|
632
|
+
// 解析失败:拒绝请求,避免 claude 无限等待审批。
|
|
633
|
+
getLogger().warn(`[${this.logTag}] AskUserQuestion parse failed, denying requestId=${requestId}`);
|
|
634
|
+
void this.writeControlResponse(requestId, {
|
|
635
|
+
behavior: 'deny',
|
|
636
|
+
message: '无法解析 AskUserQuestion 选项',
|
|
637
|
+
}).catch(() => { });
|
|
638
|
+
// review P2-1:解析失败 deny 后同样释放条目(不经过 respondPermission)。
|
|
639
|
+
this.pendingToolInputs.delete(requestId);
|
|
640
|
+
return [];
|
|
641
|
+
}
|
|
642
|
+
const view = {
|
|
643
|
+
requestId,
|
|
644
|
+
kind: 'question',
|
|
645
|
+
questions,
|
|
646
|
+
availableDecisions: [],
|
|
647
|
+
};
|
|
648
|
+
return [
|
|
649
|
+
{
|
|
650
|
+
type: 'approval_requested',
|
|
651
|
+
requestId,
|
|
652
|
+
kind: 'question',
|
|
653
|
+
threadId: this.sessionId,
|
|
654
|
+
turnId: '',
|
|
655
|
+
itemId: '',
|
|
656
|
+
view,
|
|
657
|
+
timestamp: now,
|
|
658
|
+
},
|
|
659
|
+
];
|
|
660
|
+
}
|
|
661
|
+
const command = typeof input.command === 'string' ? input.command : JSON.stringify(input ?? {});
|
|
662
|
+
const view = {
|
|
663
|
+
requestId,
|
|
664
|
+
kind: 'command',
|
|
665
|
+
command: command.slice(0, 500),
|
|
666
|
+
commandCwd: this.cwd,
|
|
667
|
+
reason: typeof request.description === 'string' ? request.description : undefined,
|
|
668
|
+
// 允许所有(acceptAll)= 允许当前 + 本会话后续自动放行。
|
|
669
|
+
availableDecisions: ['accept', 'decline', 'acceptAll'],
|
|
670
|
+
};
|
|
671
|
+
getLogger().info(`[${this.logTag}] permission request requestId=${requestId} tool=${toolName}`);
|
|
672
|
+
return [
|
|
673
|
+
{
|
|
674
|
+
type: 'approval_requested',
|
|
675
|
+
requestId,
|
|
676
|
+
kind: 'command',
|
|
677
|
+
threadId: this.sessionId,
|
|
678
|
+
turnId: '',
|
|
679
|
+
itemId: '',
|
|
680
|
+
view,
|
|
681
|
+
timestamp: now,
|
|
682
|
+
},
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
/** 统一方案:所有 runner 自己生成 timestamp(缺失时补)。 */
|
|
686
|
+
withTimestamp(event) {
|
|
687
|
+
if (!event.timestamp) {
|
|
688
|
+
event.timestamp = new Date().toISOString();
|
|
689
|
+
}
|
|
690
|
+
return event;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** 是否为 turn 终态 result(compact/compaction 是中途压缩,不是结束)。 */
|
|
694
|
+
function isTurnResultEvent(ev) {
|
|
695
|
+
if (ev.type !== 'result')
|
|
696
|
+
return false;
|
|
697
|
+
const subtype = ev.subtype;
|
|
698
|
+
return !(subtype !== undefined && COMPACTION_RESULT_SUBTYPES.has(subtype));
|
|
699
|
+
}
|
|
700
|
+
/** 解析 AskUserQuestion input.questions。 */
|
|
701
|
+
function parseUserQuestions(input) {
|
|
702
|
+
const rawQuestions = input.questions;
|
|
703
|
+
if (!Array.isArray(rawQuestions))
|
|
704
|
+
return [];
|
|
705
|
+
const questions = [];
|
|
706
|
+
const seenQuestions = new Set();
|
|
707
|
+
for (const raw of rawQuestions) {
|
|
708
|
+
if (!raw || typeof raw !== 'object')
|
|
709
|
+
continue;
|
|
710
|
+
const q = raw;
|
|
711
|
+
const question = typeof q.question === 'string' ? q.question : '';
|
|
712
|
+
if (!question)
|
|
713
|
+
continue;
|
|
714
|
+
// review P3-2:answers 字典以问题文本为 key,重复文本会互相覆盖——
|
|
715
|
+
// 视为解析失败(调用方 deny 兜底),不把坏问题上抛给用户。
|
|
716
|
+
if (seenQuestions.has(question))
|
|
717
|
+
return [];
|
|
718
|
+
seenQuestions.add(question);
|
|
719
|
+
const options = [];
|
|
720
|
+
if (Array.isArray(q.options)) {
|
|
721
|
+
for (const rawOption of q.options) {
|
|
722
|
+
if (!rawOption || typeof rawOption !== 'object')
|
|
723
|
+
continue;
|
|
724
|
+
const o = rawOption;
|
|
725
|
+
const label = typeof o.label === 'string' ? o.label : '';
|
|
726
|
+
if (!label)
|
|
727
|
+
continue;
|
|
728
|
+
options.push({
|
|
729
|
+
label,
|
|
730
|
+
...(typeof o.description === 'string' && o.description
|
|
731
|
+
? { description: o.description }
|
|
732
|
+
: {}),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// review P3-2:零选项问题无法作答(多选连自定义答案都没有),视为
|
|
737
|
+
// 解析失败(调用方 deny 兜底),不把坏问题上抛给用户。
|
|
738
|
+
if (options.length === 0)
|
|
739
|
+
return [];
|
|
740
|
+
questions.push({
|
|
741
|
+
question,
|
|
742
|
+
...(typeof q.header === 'string' && q.header ? { header: q.header } : {}),
|
|
743
|
+
...(q.multiSelect === true ? { multiSelect: true } : {}),
|
|
744
|
+
options,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
return questions;
|
|
748
|
+
}
|
|
749
|
+
//# sourceMappingURL=session.js.map
|