lark-remote 0.1.5 → 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 +39 -0
- package/README.en.md +5 -1
- package/README.md +10 -2
- package/dist/bridge/approval-coordinator.d.ts +131 -0
- package/dist/bridge/approval-coordinator.js +409 -0
- package/dist/bridge/inbound-media.d.ts +62 -0
- package/dist/bridge/inbound-media.js +282 -0
- package/dist/bridge/index.d.ts +138 -5
- package/dist/bridge/index.js +569 -65
- package/dist/bridge/queue-manager.d.ts +11 -1
- package/dist/bridge/queue-manager.js +20 -11
- package/dist/card/approval-render.d.ts +16 -0
- package/dist/card/approval-render.js +363 -0
- 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 +13 -10
- package/dist/card/card-shared.js +27 -24
- 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 +140 -202
- package/dist/card/run-state.d.ts +38 -3
- package/dist/card/run-state.js +221 -11
- 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/dir.js +1 -1
- package/dist/config/index.d.ts +116 -9
- package/dist/config/index.js +122 -29
- package/dist/connector/index.d.ts +74 -2
- package/dist/connector/index.js +119 -1
- package/dist/index.js +139 -99
- 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.d.ts +1 -1
- package/dist/router/config/codex.js +33 -5
- 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 +9 -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 +12 -3
- package/dist/router/index.d.ts +77 -17
- package/dist/router/index.js +455 -549
- package/dist/router/utils.d.ts +4 -0
- package/dist/router/utils.js +9 -2
- 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 +519 -0
- package/dist/runner/codex/app-server/protocol-types.js +56 -0
- package/dist/runner/codex/app-server/runner.d.ts +104 -0
- package/dist/runner/codex/app-server/runner.js +377 -0
- package/dist/runner/codex/app-server/translator.d.ts +89 -0
- package/dist/runner/codex/app-server/translator.js +716 -0
- package/dist/runner/codex/index.d.ts +1 -1
- package/dist/runner/codex/index.js +1 -1
- 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/common/jsonrpc/client.d.ts +89 -0
- package/dist/runner/common/jsonrpc/client.js +243 -0
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +205 -0
- package/dist/runner/common/jsonrpc/transport.d.ts +50 -0
- package/dist/runner/common/jsonrpc/transport.js +230 -0
- 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/common/usage.d.ts +2 -0
- package/dist/runner/common/usage.js +2 -0
- package/dist/runner/index.d.ts +5 -5
- 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 +153 -2
- 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 +57 -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/argv.d.ts +0 -33
- package/dist/runner/codex/argv.js +0 -40
- 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 -63
- package/dist/runner/codex/runner.js +0 -124
- 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
package/dist/bridge/index.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { sleep } from '../common/sleep.js';
|
|
2
3
|
import { getLogger } from '../logger/index.js';
|
|
3
4
|
import { RunCardSession } from '../card/run-card-session.js';
|
|
4
5
|
import { renderRunCard } from '../card/run-renderer.js';
|
|
5
6
|
import { renderBashCard } from '../card/bash-renderer.js';
|
|
6
7
|
import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
|
|
7
|
-
import {
|
|
8
|
-
import { sessionEventPanel } from '../router/card-helpers.js';
|
|
9
|
-
import { formatUsageStats } from '../router/index.js';
|
|
8
|
+
import { buildSessionHistoryCard } from '../router/card-helpers.js';
|
|
10
9
|
import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
|
|
11
10
|
import { enforceCardBudget } from '../card/card-budget.js';
|
|
12
11
|
import { normalizeResultUsage } from '../runner/common/usage.js';
|
|
13
12
|
import { BashProcessRunner } from '../runner/index.js';
|
|
14
13
|
import { QueueManager, } from './queue-manager.js';
|
|
14
|
+
import { ApprovalCoordinator, decisionToApprovalAction } from './approval-coordinator.js';
|
|
15
|
+
import { InboundMediaHandler } from './inbound-media.js';
|
|
15
16
|
/**
|
|
16
17
|
* Max events to read for the completion notification card (mirror router's
|
|
17
18
|
* AUTO_RESUME_MAX_EVENTS = 5). Without a cap, readSessionContent returns every
|
|
@@ -19,6 +20,14 @@ import { QueueManager, } from './queue-manager.js';
|
|
|
19
20
|
* budget (review §P1-21).
|
|
20
21
|
*/
|
|
21
22
|
const COMPLETION_NOTIFICATION_MAX_EVENTS = 5;
|
|
23
|
+
/** Approval request timeout (ms) — pending approval expires after this. */
|
|
24
|
+
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* runner 缓存的工作区数量上限(review P3-3):超过后创建新工作区槽位时
|
|
27
|
+
* 顺带回收「非 active 且进程已停」的死缓存,防止随访问过的工作区数量
|
|
28
|
+
* 无界增长。活进程/在途 run 一律保留(workspace-lifetime 长驻连接不能误杀)。
|
|
29
|
+
*/
|
|
30
|
+
const MAX_CACHED_RUNNER_WORKSPACES = 10;
|
|
22
31
|
/**
|
|
23
32
|
* Max idle time (no stdout events) before a claude run is considered hung
|
|
24
33
|
* (§9.12). When this fires the bridge calls `runner.stop()` to unblock the
|
|
@@ -58,7 +67,9 @@ function terminalReactionEmoji(terminal) {
|
|
|
58
67
|
* Runner/BridgeChannel seams.
|
|
59
68
|
*/
|
|
60
69
|
export class Bridge {
|
|
70
|
+
/** 飞书连接通道(public:集成测试的 seam,替代 as unknown as 访问)。 */
|
|
61
71
|
connector;
|
|
72
|
+
/** 会话存储(public:测试辅助读取/写入 cwd 等,替代私有访问)。 */
|
|
62
73
|
sessionStore;
|
|
63
74
|
config;
|
|
64
75
|
workspaceStore;
|
|
@@ -67,6 +78,8 @@ export class Bridge {
|
|
|
67
78
|
agentRegistry;
|
|
68
79
|
/** Session reader registry for usage and completion cards. */
|
|
69
80
|
sessionReaderRegistry;
|
|
81
|
+
/** 入站媒体(图片/文件)落盘 + 合批提示。 */
|
|
82
|
+
mediaHandler;
|
|
70
83
|
/**
|
|
71
84
|
* Per-workspace runner instances, keyed by (cwd, agentKind).
|
|
72
85
|
* Fix 4 (2026-07-18): Each agentKind has its own cache slot, so switching
|
|
@@ -77,9 +90,17 @@ export class Bridge {
|
|
|
77
90
|
*/
|
|
78
91
|
runners = new Map();
|
|
79
92
|
/** Queue manager for per-workspace serial processing queue. */
|
|
93
|
+
/** 普通消息队列管理器(public:测试注入 queue-card 状态用)。 */
|
|
80
94
|
queueManager;
|
|
81
95
|
/** Active runs per workspace (cwd). Multiple workspaces can run in parallel. */
|
|
82
96
|
activeRuns = new Map();
|
|
97
|
+
/** Approval coordinators keyed by runId. */
|
|
98
|
+
approvalCoordinators = new Map();
|
|
99
|
+
/**
|
|
100
|
+
* 最近一次已完成的 codex app-server run(按 cwd)。run 结束后 activeRuns
|
|
101
|
+
* 已清空,Compact 需要它来校验 runId 并提供 sessionId。
|
|
102
|
+
*/
|
|
103
|
+
lastCompactableCodexRun = new Map();
|
|
83
104
|
/**
|
|
84
105
|
* Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
|
|
85
106
|
* queue and may run in parallel with claude runs / other bash runs in the same
|
|
@@ -96,6 +117,11 @@ export class Bridge {
|
|
|
96
117
|
this.agentRegistry = deps.agentRegistry;
|
|
97
118
|
this.sessionReaderRegistry = deps.sessionReaderRegistry;
|
|
98
119
|
this.queueManager = new QueueManager((workspace) => this.activeRuns.has(workspace), (chatId, card, opts) => this.connector.sendWithRetry(chatId, { card }, { replyTo: opts?.replyTo }), (messageId, card) => this.connector.updateCard(messageId, card));
|
|
120
|
+
this.mediaHandler = new InboundMediaHandler({
|
|
121
|
+
resolveCwd: (userId) => this.resolveCwd(userId),
|
|
122
|
+
getConfig: () => this.config,
|
|
123
|
+
send: async (ctx, text) => this.sendResult({ text }, ctx),
|
|
124
|
+
});
|
|
99
125
|
}
|
|
100
126
|
/**
|
|
101
127
|
* Get or create a runner for the given workspace, keyed by current defaultAgent.
|
|
@@ -110,6 +136,20 @@ export class Bridge {
|
|
|
110
136
|
if (workspaceMap?.has(kind)) {
|
|
111
137
|
return workspaceMap.get(kind);
|
|
112
138
|
}
|
|
139
|
+
// review P3-3:缓存超过上限且是新工作区时才回收死槽位——刚创建未 run
|
|
140
|
+
// 的 runner 同样是 isRunning=false,小缓存下立即回收会破坏「创建即注册」
|
|
141
|
+
// 的既有语义(exit-dispatcher 计数回归)。
|
|
142
|
+
if (this.runners.size >= MAX_CACHED_RUNNER_WORKSPACES && !this.runners.has(workspace)) {
|
|
143
|
+
for (const [cwd, slotMap] of [...this.runners.entries()]) {
|
|
144
|
+
if (this.activeRuns.has(cwd))
|
|
145
|
+
continue;
|
|
146
|
+
for (const [slotKind, r] of [...slotMap.entries()]) {
|
|
147
|
+
if (!r.isRunning) {
|
|
148
|
+
this.evictRunnerSlot(cwd, slotKind);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
113
153
|
// Create new runner for this (workspace, kind) slot
|
|
114
154
|
const runner = this.agentRegistry.get(kind, workspace);
|
|
115
155
|
// Call lifecycle methods for agent runners created via factory
|
|
@@ -137,10 +177,69 @@ export class Bridge {
|
|
|
137
177
|
container.current = config;
|
|
138
178
|
}
|
|
139
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Push updated approval-mode settings to active workspace-lifetime runners.
|
|
182
|
+
* `clearRunners()` deliberately skips workspaces with active runs; this
|
|
183
|
+
* gives those runners a chance to apply the new settings immediately via
|
|
184
|
+
* the unified duck method `updateApprovalMode` (codex → thread/settings/
|
|
185
|
+
* update, kimi → session/set_mode, opencode → session/set_mode) instead of
|
|
186
|
+
* keeping a stale snapshot until the runner is evicted/recreated.
|
|
187
|
+
*/
|
|
188
|
+
syncActiveApprovalModes() {
|
|
189
|
+
for (const [cwd, active] of this.activeRuns) {
|
|
190
|
+
const runner = active.runner;
|
|
191
|
+
if (typeof runner.updateApprovalMode !== 'function')
|
|
192
|
+
continue;
|
|
193
|
+
const settings = this.approvalModeSettingsFor(active.agentKind);
|
|
194
|
+
if (!settings)
|
|
195
|
+
continue;
|
|
196
|
+
void runner.updateApprovalMode(settings).catch((err) => {
|
|
197
|
+
getLogger().warn(`[bridge] sync approval mode failed cwd=${cwd}: ${err.message}`);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Per-agent approval-mode settings snapshot from the current config. */
|
|
202
|
+
approvalModeSettingsFor(kind) {
|
|
203
|
+
const agents = this.config.agents;
|
|
204
|
+
switch (kind) {
|
|
205
|
+
case 'codex': {
|
|
206
|
+
const codexConfig = agents?.codex;
|
|
207
|
+
if (!codexConfig)
|
|
208
|
+
return undefined;
|
|
209
|
+
return {
|
|
210
|
+
approvalPolicy: codexConfig.approvalPolicy,
|
|
211
|
+
sandbox: codexConfig.sandbox,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
case 'kimi': {
|
|
215
|
+
const kimiConfig = agents?.kimi;
|
|
216
|
+
if (!kimiConfig)
|
|
217
|
+
return undefined;
|
|
218
|
+
return { permissionMode: kimiConfig.permissionMode };
|
|
219
|
+
}
|
|
220
|
+
case 'opencode': {
|
|
221
|
+
const opencodeConfig = agents?.opencode;
|
|
222
|
+
if (!opencodeConfig)
|
|
223
|
+
return undefined;
|
|
224
|
+
return { mode: opencodeConfig.mode };
|
|
225
|
+
}
|
|
226
|
+
default:
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
140
230
|
/** Get the current runner for a workspace. Used by router for agent-specific commands (P3.2). */
|
|
141
231
|
getCurrentRunner(workspace) {
|
|
142
232
|
return this.getRunner(workspace);
|
|
143
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Whether the runner for the given agent kind has runCompact capability.
|
|
236
|
+
* Used by router for compact button gating (design doc §6.2-2: duck-typing
|
|
237
|
+
* instead of hardcoding agentKind === 'codex').
|
|
238
|
+
*/
|
|
239
|
+
hasRunCompact(workspace, kind = this.config.defaultAgent) {
|
|
240
|
+
const runner = this.getRunner(workspace, kind);
|
|
241
|
+
return ('runCompact' in runner && typeof runner.runCompact === 'function');
|
|
242
|
+
}
|
|
144
243
|
/** 入队时刻快照:当前 defaultAgent + 该 agent 的 sessionId(无 session 则 undefined)。
|
|
145
244
|
* 唯一捕获点:排队消息在 T0 把 agent+session 钉进 AgentBinding,随任务闭包带到 T1
|
|
146
245
|
* 执行时刻,避免 /new、/config 在排队期间改写 live 状态导致语义漂移(方案 D4)。 */
|
|
@@ -182,6 +281,11 @@ export class Bridge {
|
|
|
182
281
|
if (evicted && typeof evicted.unregisterExitHandlers === 'function') {
|
|
183
282
|
evicted.unregisterExitHandlers();
|
|
184
283
|
}
|
|
284
|
+
// App-server runner owns a persistent connection — dispose it so the
|
|
285
|
+
// `codex app-server` child process does not leak across runs/evictions.
|
|
286
|
+
if (evicted && typeof evicted.dispose === 'function') {
|
|
287
|
+
void evicted.dispose();
|
|
288
|
+
}
|
|
185
289
|
workspaceMap.delete(kind);
|
|
186
290
|
if (workspaceMap.size === 0) {
|
|
187
291
|
this.runners.delete(cwd);
|
|
@@ -201,6 +305,7 @@ export class Bridge {
|
|
|
201
305
|
* Extract agent-specific run options from config.
|
|
202
306
|
* Returns model/effort/etc. based on current defaultAgent.
|
|
203
307
|
*/
|
|
308
|
+
/** 解析某 agent 的 run 选项(forwardToClaude 用;测试直接调用的 seam)。 */
|
|
204
309
|
getAgentRunOptions(agent = this.config.defaultAgent) {
|
|
205
310
|
const agents = this.config.agents;
|
|
206
311
|
const result = {};
|
|
@@ -406,7 +511,10 @@ export class Bridge {
|
|
|
406
511
|
// its slot interrupted, hiding it from queue cards (A22).
|
|
407
512
|
const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
|
|
408
513
|
await Promise.allSettled([
|
|
409
|
-
|
|
514
|
+
// 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
|
|
515
|
+
// interruptedReason,与审批超时自动取消(approval_timeout)、审批
|
|
516
|
+
// 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
|
|
517
|
+
active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
|
|
410
518
|
runner.stop({ immediate: true }),
|
|
411
519
|
]);
|
|
412
520
|
// The chain may have advanced while the stop was in flight: a new run
|
|
@@ -483,6 +591,15 @@ export class Bridge {
|
|
|
483
591
|
}
|
|
484
592
|
catch (err) {
|
|
485
593
|
getLogger().error('[bridge] failed to send result:', err);
|
|
594
|
+
// 不静默:向用户发送一条纯文本消息,告知具体错误(如飞书 11310 element exceeds
|
|
595
|
+
// the limit)。如果用户看到空白无反馈,无法判断是命令无效还是卡片发送失败。
|
|
596
|
+
try {
|
|
597
|
+
const errMsg = err.message ?? String(err);
|
|
598
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: `⚠️ 卡片发送失败:${errMsg}` }, { replyTo: ctx.messageId });
|
|
599
|
+
}
|
|
600
|
+
catch (fallbackErr) {
|
|
601
|
+
getLogger().error('[bridge] fallback text send also failed:', fallbackErr);
|
|
602
|
+
}
|
|
486
603
|
return false;
|
|
487
604
|
}
|
|
488
605
|
}
|
|
@@ -511,7 +628,10 @@ export class Bridge {
|
|
|
511
628
|
return true;
|
|
512
629
|
}
|
|
513
630
|
catch (err) {
|
|
514
|
-
|
|
631
|
+
const errMsg = err.message ?? String(err);
|
|
632
|
+
getLogger().warn(`[bridge] updateCardInPlace failed, falling back to sendResult: ${errMsg}`);
|
|
633
|
+
// 先尝试 fallback 发卡片;若卡片本身有问题(如 11310),
|
|
634
|
+
// sendResult 内部 catch 会发纯文本通知用户。
|
|
515
635
|
await this.sendResult({ card }, ctx);
|
|
516
636
|
return false;
|
|
517
637
|
}
|
|
@@ -527,6 +647,18 @@ export class Bridge {
|
|
|
527
647
|
await this.sendResult({ text: `发送文件失败: ${err.message}` }, ctx);
|
|
528
648
|
}
|
|
529
649
|
}
|
|
650
|
+
/** 入站媒体落盘入口(connector 下载完成后调用)。 */
|
|
651
|
+
async onInboundMedia(payload) {
|
|
652
|
+
await this.mediaHandler.handle(payload);
|
|
653
|
+
}
|
|
654
|
+
/** 文本消息到达时立即冲刷待合批的媒体保存提示。 */
|
|
655
|
+
flushMediaNotifications(userId, chatId) {
|
|
656
|
+
this.mediaHandler.flushAll(userId, chatId);
|
|
657
|
+
}
|
|
658
|
+
/** 冲刷全部待合批提示(/exit、/restart 干净退出前调用)。 */
|
|
659
|
+
async flushAllMediaNotifications() {
|
|
660
|
+
await this.mediaHandler.flushAllPending();
|
|
661
|
+
}
|
|
530
662
|
/** Reconnect the underlying Feishu channel. */
|
|
531
663
|
async reconnect() {
|
|
532
664
|
await this.connector.reconnect();
|
|
@@ -641,6 +773,9 @@ export class Bridge {
|
|
|
641
773
|
* Handles idle watchdog, event processing, session sync, and state transitions.
|
|
642
774
|
*/
|
|
643
775
|
async runAgentStreamToEnd(runner, cardSession, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind = this.config.defaultAgent) {
|
|
776
|
+
// Runner 声明的 usage 权威来源(review P3-7):codex app-server = 'live'
|
|
777
|
+
// (turn/started 的 tokenUsage.last 是本 turn 增量)。其余 runner 未声明。
|
|
778
|
+
const usageAuthority = runner.getUsageAuthority?.();
|
|
644
779
|
// P2-1: idle watchdog 用单 interval + lastEventTs 截止时间判定,而非每事件
|
|
645
780
|
// clearTimeout + setTimeout 重建 timer。高频事件流下 timer 重建次数与事件数
|
|
646
781
|
// 成正比(N 事件 = N 次 setTimeout + N 个闭包/秒);interval 方案只建一个
|
|
@@ -650,7 +785,7 @@ export class Bridge {
|
|
|
650
785
|
let idleInterval = null;
|
|
651
786
|
let sawResult = false;
|
|
652
787
|
// 记录 result 是否为 error: error 时跳过 jsonl usage 读取 (见 stream end 处说明)
|
|
653
|
-
let
|
|
788
|
+
let resultNotSuccess = false;
|
|
654
789
|
// Final usage read from jsonl after the run completes. live stream-json does
|
|
655
790
|
// not emit compact_boundary, so the live contextLength is unreliable.
|
|
656
791
|
let finalContextLength;
|
|
@@ -664,6 +799,10 @@ export class Bridge {
|
|
|
664
799
|
// done card shows real values instead of the 10% estimate.
|
|
665
800
|
let liveInputTokens;
|
|
666
801
|
let liveOutputTokens;
|
|
802
|
+
// Real context window limit from the live result event (codex app-server
|
|
803
|
+
// carries it in ResultEvent.usage.context_limit). jsonl wins when present;
|
|
804
|
+
// live is the fallback (app-server error runs / jsonl 未落盘时).
|
|
805
|
+
let liveContextLimit;
|
|
667
806
|
// Final input/output for the done card: live value wins (codex/opencode
|
|
668
807
|
// carry per-run usage in the result event); jsonl is the fallback for
|
|
669
808
|
// agents whose live stream has no usage (kimi).
|
|
@@ -697,6 +836,15 @@ export class Bridge {
|
|
|
697
836
|
// "15min 无事件即 stop" 的语义不变(§9.12)。周期取 idleTimeoutMs/2,使停滞
|
|
698
837
|
// 后最多在 1.5× idleTimeoutMs 内触发(半个窗口延迟,watchdog 语义可接受)。
|
|
699
838
|
const fireIdleTimeout = () => {
|
|
839
|
+
// 权限等待期间暂停空闲看门狗:claude/codex 等待人工决策时无 stdout
|
|
840
|
+
// 事件,「等审批」不是「挂死」。审批由 ApprovalCoordinator 的超时
|
|
841
|
+
// (默认 5 分钟 < 看门狗窗口)自愈:cancel 送达 + 中断 turn,不会无限
|
|
842
|
+
// 挂起。
|
|
843
|
+
const pendingApproval = this.approvalCoordinators.get(runId)?.pendingCount() ?? 0;
|
|
844
|
+
if (pendingApproval > 0) {
|
|
845
|
+
lastEventTs = Date.now();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
700
848
|
if (idleInterval)
|
|
701
849
|
clearInterval(idleInterval);
|
|
702
850
|
idleInterval = null;
|
|
@@ -746,7 +894,7 @@ export class Bridge {
|
|
|
746
894
|
// 代际守卫:run 在途时 /new(或 new-session 卡片、/cd、/resume、
|
|
747
895
|
// /config 切换)移动了 session 指针,此 init 的写回是 stale 的——
|
|
748
896
|
// 跳过,否则 /new 的清空会被在途 run 静默撤销(2026-08-09 事故:
|
|
749
|
-
// task-notification 注入触发 Claude 重发 init
|
|
897
|
+
// task-notification 注入触发 Claude 重发 init,已终止的 run 被复活)。
|
|
750
898
|
const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
|
|
751
899
|
if (pointerMoved) {
|
|
752
900
|
getLogger().info(`[bridge] system.init write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.session_id}`);
|
|
@@ -773,6 +921,20 @@ export class Bridge {
|
|
|
773
921
|
}
|
|
774
922
|
getLogger().info(`[bridge] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
|
|
775
923
|
}
|
|
924
|
+
if (event.type === 'turn_started') {
|
|
925
|
+
// app-server runner 在 turn setup 成功后补发 synthetic system.init
|
|
926
|
+
// (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
|
|
927
|
+
// threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
|
|
928
|
+
// 都新建线程(thread/start 而非 thread/resume),会话无法延续。
|
|
929
|
+
const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
|
|
930
|
+
if (!pointerMoved) {
|
|
931
|
+
this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.threadId, cwd);
|
|
932
|
+
getLogger().info(`[bridge] turn_started session write-back runId=${runId} sessionId=${event.threadId}`);
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
getLogger().info(`[bridge] turn_started write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.threadId}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
776
938
|
// Track context length from compaction events
|
|
777
939
|
if (event.type === 'system' &&
|
|
778
940
|
event.subtype === 'compact_boundary' &&
|
|
@@ -786,7 +948,14 @@ export class Bridge {
|
|
|
786
948
|
// also guards on sessionId === undefined) from transitioning to
|
|
787
949
|
// finalizing too early. The real result for this run arrives after
|
|
788
950
|
// system.init.
|
|
789
|
-
|
|
951
|
+
//
|
|
952
|
+
// Exception: app-server runners (usageAuthority === 'live') 的 init
|
|
953
|
+
// 是 runner 补发的 synthetic init(2026-08-13 修复)。此分支保留为
|
|
954
|
+
// 防御性兜底:任何 live runner 若在无 init 时发出 result(如旧构建、
|
|
955
|
+
// 其它 live runner),session 写回仍执行,但 sawResult 保持 false,
|
|
956
|
+
// 卡片不会把半初始化的 run 当作已收尾。
|
|
957
|
+
const preInitResult = !sawInit;
|
|
958
|
+
if (preInitResult && usageAuthority !== 'live') {
|
|
790
959
|
getLogger().info(`[bridge] pre-init result ignored (resume replay) runId=${runId}`);
|
|
791
960
|
// Still reset idle timer — the CLI is alive and producing events.
|
|
792
961
|
resetIdle();
|
|
@@ -795,9 +964,24 @@ export class Bridge {
|
|
|
795
964
|
// layer avoids the push + render overhead for a no-op event.
|
|
796
965
|
continue;
|
|
797
966
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
967
|
+
if (!preInitResult) {
|
|
968
|
+
sawResult = true;
|
|
969
|
+
}
|
|
970
|
+
resultNotSuccess = event.subtype !== 'success';
|
|
971
|
+
getLogger().info(preInitResult
|
|
972
|
+
? `[bridge] pre-init result from live runner (app-server setup failure) runId=${runId}`
|
|
973
|
+
: `[bridge] result event received runId=${runId}`);
|
|
974
|
+
// app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)
|
|
975
|
+
// 不会有 turn_started 事件,store 不会写回新线程 id;runner 已在 result
|
|
976
|
+
// 里兜底上报(review P3-10)。此处仅当 runner 声明 live usage(app-server)
|
|
977
|
+
// 且会话指针未动时写回,避免下条消息再开一个孤儿线程。其余 agent 不在
|
|
978
|
+
// 此列(它们各自有 system.init / turn_started 写回)。
|
|
979
|
+
if (usageAuthority === 'live' &&
|
|
980
|
+
event.session_id &&
|
|
981
|
+
this.sessionStore.getSessionEpoch(ctx.userId, agentKind) === sessionEpochAtStart) {
|
|
982
|
+
this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.session_id, cwd);
|
|
983
|
+
getLogger().info(`[bridge] result session write-back runId=${runId} sessionId=${event.session_id}`);
|
|
984
|
+
}
|
|
801
985
|
// result 后进入 finalizing 非终态(暂存 subtype/errorMsg),由 for-await
|
|
802
986
|
// 自然结束后在 finally 转 done/error。提取 usage 信息。
|
|
803
987
|
if (event.usage) {
|
|
@@ -814,6 +998,7 @@ export class Bridge {
|
|
|
814
998
|
// Input/Output 是本 run、Cache/Total 是 session 累计的混 scope 问题。
|
|
815
999
|
liveInputTokens = u.inputTokens;
|
|
816
1000
|
liveOutputTokens = u.outputTokens;
|
|
1001
|
+
liveContextLimit = u.contextLimit;
|
|
817
1002
|
if (u.cacheReadTokens !== undefined) {
|
|
818
1003
|
finalCacheReadTokens = u.cacheReadTokens;
|
|
819
1004
|
}
|
|
@@ -825,6 +1010,59 @@ export class Bridge {
|
|
|
825
1010
|
}
|
|
826
1011
|
}
|
|
827
1012
|
}
|
|
1013
|
+
// Approval events (Codex app-server mode)
|
|
1014
|
+
if (event.type === 'approval_requested') {
|
|
1015
|
+
// 审批请求到达:撤回 Typing 表情再重发,让飞书重新触发提醒,把用户
|
|
1016
|
+
// 注意力拉回待审批的卡片(run 在等待人工决策,Typing 应重新闪烁)。
|
|
1017
|
+
void (async () => {
|
|
1018
|
+
try {
|
|
1019
|
+
await this.connector.removeReactionByEmoji(ctx.messageId, 'Typing');
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
getLogger().warn(`[bridge] remove Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
await this.connector.addReaction(ctx.messageId, 'Typing');
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
getLogger().warn(`[bridge] re-add Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
|
|
1029
|
+
}
|
|
1030
|
+
})();
|
|
1031
|
+
let coordinator = this.approvalCoordinators.get(runId);
|
|
1032
|
+
if (!coordinator) {
|
|
1033
|
+
coordinator = new ApprovalCoordinator({
|
|
1034
|
+
// claude 审批超时走 config(claude.approvalTimeoutMs,默认 5 分钟,
|
|
1035
|
+
// 对齐 codex 红线勿改短);codex 维持模块常量。
|
|
1036
|
+
approvalTimeoutMs: agentKind === 'claude'
|
|
1037
|
+
? (this.config.claude?.approvalTimeoutMs ?? APPROVAL_TIMEOUT_MS)
|
|
1038
|
+
: APPROVAL_TIMEOUT_MS,
|
|
1039
|
+
responder: async (requestId, response) => {
|
|
1040
|
+
const r = runner;
|
|
1041
|
+
if (typeof r.respondApproval === 'function') {
|
|
1042
|
+
await r.respondApproval(requestId, response);
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
interruptTurn: () => runner.stop(),
|
|
1046
|
+
pushToCard: async (events) => {
|
|
1047
|
+
for (const ev of events)
|
|
1048
|
+
await cardSession.push(ev);
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
this.approvalCoordinators.set(runId, coordinator);
|
|
1052
|
+
getLogger().info(`[bridge] approval coordinator created runId=${runId.slice(0, 8)} requestId=${event.requestId}`);
|
|
1053
|
+
}
|
|
1054
|
+
coordinator.onRequested(event);
|
|
1055
|
+
}
|
|
1056
|
+
else if (event.type === 'approval_resolved') {
|
|
1057
|
+
const coordinator = this.approvalCoordinators.get(runId);
|
|
1058
|
+
coordinator?.onResolved(event.requestId);
|
|
1059
|
+
}
|
|
1060
|
+
else if (event.type === 'approval_view_updated') {
|
|
1061
|
+
// 乱序流:审批先到、item/started 后到,补全审批卡内容。coordinator
|
|
1062
|
+
// 仅在 pending 时更新(已响应/过期不复活)。
|
|
1063
|
+
const coordinator = this.approvalCoordinators.get(runId);
|
|
1064
|
+
coordinator?.updateView(event.requestId, event.view);
|
|
1065
|
+
}
|
|
828
1066
|
// §9.12 红线(§P1-2 方案 A):每个事件(含 result)无条件刷新
|
|
829
1067
|
// lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
|
|
830
1068
|
// 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
|
|
@@ -846,28 +1084,24 @@ export class Bridge {
|
|
|
846
1084
|
// 立即 exit=1), 此时 jsonl 里是上一次成功 turn 的历史 usage, 会被误当作本次 usage 显示,
|
|
847
1085
|
// 误导用户以为本次出错的请求也消耗了 token。error 时只用 live 捕获的 usage (若有)。
|
|
848
1086
|
const finalSessionId = this.sessionStore.getSessionId(ctx.userId, agentKind);
|
|
849
|
-
const finalUsage =
|
|
1087
|
+
const finalUsage = resultNotSuccess
|
|
850
1088
|
? undefined
|
|
851
1089
|
: this.resolveFinalUsage(finalSessionId, cwd, agentKind);
|
|
852
1090
|
finalContextLength = finalUsage?.contextLength ?? contextLength;
|
|
853
|
-
finalContextLimit = finalUsage?.contextLimit;
|
|
1091
|
+
finalContextLimit = finalUsage?.contextLimit ?? liveContextLimit;
|
|
854
1092
|
finalCompactCount = finalUsage?.compactCount;
|
|
855
1093
|
finalCumulativeTotalTokens = finalUsage?.cumulativeTotalTokens;
|
|
856
1094
|
finalCumulativeInputTokens = finalUsage?.cumulativeInputTokens;
|
|
857
1095
|
finalCumulativeOutputTokens = finalUsage?.cumulativeOutputTokens;
|
|
858
1096
|
finalCumulativeCacheReadTokens = finalUsage?.cumulativeCacheReadTokens;
|
|
859
1097
|
finalCumulativeCacheCreationTokens = finalUsage?.cumulativeCacheCreationTokens;
|
|
860
|
-
// 统一 flow 字段 scope:live
|
|
1098
|
+
// 统一 flow 字段 scope:live 优先,jsonl 兜底。
|
|
861
1099
|
// 当 live result 事件带了 input/output(即 liveInputTokens/liveOutputTokens
|
|
862
1100
|
// 非 undefined),所有 flow 字段统一用 live scope(本 run),不用 jsonl
|
|
863
1101
|
// 的 session 累计值覆盖,避免 cache%/Total 分子分母不同源。
|
|
864
1102
|
// 无 live usage 时(如 kimi),jsonl 值兜底。
|
|
865
|
-
// codex 例外(2026-08-01):codex exec 的 turn.completed.usage 是会话累计
|
|
866
|
-
// total_token_usage(resume 长会话下虚高数百倍),不是本 run 增量——live
|
|
867
|
-
// 值必须被忽略,flow 字段一律取 jsonl 主线程文件的 per-turn 值;
|
|
868
|
-
// jsonl 缺失(老版本无 token_count)时才回退 live。
|
|
869
1103
|
// contextLength/compactCount 保持 jsonl 优先(它们是水位/历史计数)。
|
|
870
|
-
const codexJsonlFirst =
|
|
1104
|
+
const codexJsonlFirst = usageAuthority === 'jsonl';
|
|
871
1105
|
if (!codexJsonlFirst && liveInputTokens !== undefined && liveOutputTokens !== undefined) {
|
|
872
1106
|
// 非 codex 且 live 有 input/output → 所有 flow 字段用 live scope
|
|
873
1107
|
finalInputTokens = liveInputTokens;
|
|
@@ -892,9 +1126,13 @@ export class Bridge {
|
|
|
892
1126
|
// 3) 已终态 + sawResult -> 补充 usage meta(不改 terminal)
|
|
893
1127
|
const finalState = cardSession.currentState;
|
|
894
1128
|
if (finalState.terminal === 'finalizing') {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1129
|
+
// 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
|
|
1130
|
+
// 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
|
|
1131
|
+
const subtype = finalState.resultSubtype;
|
|
1132
|
+
const isError = subtype === 'error' || !!finalState.errorMsg;
|
|
1133
|
+
const terminal = isError ? 'error' : subtype === 'interrupted' ? 'interrupted' : 'done';
|
|
1134
|
+
await cardSession.finish(terminal, {
|
|
1135
|
+
resultSubtype: subtype,
|
|
898
1136
|
errorMsg: finalState.errorMsg,
|
|
899
1137
|
contextLength: finalContextLength,
|
|
900
1138
|
contextLimit: finalContextLimit,
|
|
@@ -1003,6 +1241,11 @@ export class Bridge {
|
|
|
1003
1241
|
finally {
|
|
1004
1242
|
if (idleInterval)
|
|
1005
1243
|
clearInterval(idleInterval);
|
|
1244
|
+
const coordinator = this.approvalCoordinators.get(runId);
|
|
1245
|
+
if (coordinator) {
|
|
1246
|
+
coordinator.onTurnEnded();
|
|
1247
|
+
this.approvalCoordinators.delete(runId);
|
|
1248
|
+
}
|
|
1006
1249
|
}
|
|
1007
1250
|
}
|
|
1008
1251
|
/**
|
|
@@ -1047,14 +1290,39 @@ export class Bridge {
|
|
|
1047
1290
|
// (every later message busy-dropped until a manual /stop).
|
|
1048
1291
|
if (this.activeRuns.get(cwd) === activeRun) {
|
|
1049
1292
|
this.activeRuns.delete(cwd);
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
|
|
1057
|
-
|
|
1293
|
+
// Workspace-lifetime runners (codex app-server) keep their persistent
|
|
1294
|
+
// connection across runs: evicting here would kill the connection after
|
|
1295
|
+
// EVERY run, defeating lifetime='workspace' + idle TTL. They are still
|
|
1296
|
+
// evicted+disposed by clearRunners (/config) and interruptCurrentRun
|
|
1297
|
+
// (/stop). Spawn-per-message runners are evicted as before to prevent
|
|
1298
|
+
// stale process references accumulating.
|
|
1299
|
+
if (activeRun.runner.lifetime !== 'workspace') {
|
|
1300
|
+
this.evictRunnerSlot(cwd, activeRun.agentKind);
|
|
1301
|
+
}
|
|
1302
|
+
// Compact 卡片需要 runId + sessionId;record 供终态卡上的 Compact 按钮
|
|
1303
|
+
// 校验与取参(activeRuns 已清空,无法再查到)。异常退出(error/
|
|
1304
|
+
// interrupted/idle_timeout)同样记录——上下文往往更大,压缩后再续。
|
|
1305
|
+
// §6.2-3:写入条件从「codex 终态」放宽为「runner 有 runCompact 的终态」
|
|
1306
|
+
// (codex app-server + kimi acp 均满足)。
|
|
1307
|
+
const runTerminal = cardSession.currentState.terminal;
|
|
1308
|
+
const runnerHasCompact = 'runCompact' in activeRun.runner &&
|
|
1309
|
+
typeof activeRun.runner.runCompact === 'function';
|
|
1310
|
+
if (runnerHasCompact &&
|
|
1311
|
+
activeRun.runner.lifetime === 'workspace' &&
|
|
1312
|
+
(runTerminal === 'done' ||
|
|
1313
|
+
runTerminal === 'error' ||
|
|
1314
|
+
runTerminal === 'interrupted' ||
|
|
1315
|
+
runTerminal === 'idle_timeout')) {
|
|
1316
|
+
const compactSessionId = cardSession.currentState.sessionId ??
|
|
1317
|
+
this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
|
|
1318
|
+
if (compactSessionId) {
|
|
1319
|
+
this.lastCompactableCodexRun.set(cwd, {
|
|
1320
|
+
runId: activeRun.runId,
|
|
1321
|
+
sessionId: compactSessionId,
|
|
1322
|
+
agentKind: activeRun.agentKind,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1058
1326
|
getLogger().info(`[bridge] activeRuns.delete cwd=${cwd} runId=${runId}`);
|
|
1059
1327
|
}
|
|
1060
1328
|
}
|
|
@@ -1081,6 +1349,7 @@ export class Bridge {
|
|
|
1081
1349
|
contextLength: content.usage.contextLength,
|
|
1082
1350
|
contextLimit: content.usage.contextLimit,
|
|
1083
1351
|
compactCount: content.usage.compactCount,
|
|
1352
|
+
compactPreContextLength: content.usage.compactPreContextLength,
|
|
1084
1353
|
cacheReadTokens: content.usage.cacheReadTokens,
|
|
1085
1354
|
cacheCreationTokens: content.usage.cacheCreationTokens,
|
|
1086
1355
|
totalTokens: content.usage.totalTokens,
|
|
@@ -1114,42 +1383,19 @@ export class Bridge {
|
|
|
1114
1383
|
maxEvents: COMPLETION_NOTIFICATION_MAX_EVENTS,
|
|
1115
1384
|
});
|
|
1116
1385
|
const { events, usage } = content;
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
}
|
|
1130
|
-
// Add action button to resume this session - use column_set+column for 2.0
|
|
1131
|
-
elements.push({
|
|
1132
|
-
tag: 'column_set',
|
|
1133
|
-
columns: [
|
|
1134
|
-
{
|
|
1135
|
-
tag: 'column',
|
|
1136
|
-
width: 'auto',
|
|
1137
|
-
elements: [resumeUseButton(sessionId, agentKind)],
|
|
1138
|
-
},
|
|
1139
|
-
],
|
|
1386
|
+
const card = buildSessionHistoryCard({
|
|
1387
|
+
sessionId,
|
|
1388
|
+
cwd,
|
|
1389
|
+
events,
|
|
1390
|
+
usage,
|
|
1391
|
+
}, {
|
|
1392
|
+
agentKind,
|
|
1393
|
+
headerText: `📂 \`${cwd}\`\n会话: **${sessionId}**`,
|
|
1394
|
+
title: '✅ 会话已完成',
|
|
1395
|
+
usageResult: 'completed',
|
|
1396
|
+
actions: [resumeUseButton(sessionId, agentKind)],
|
|
1397
|
+
headerTemplate: 'green',
|
|
1140
1398
|
});
|
|
1141
|
-
const card = {
|
|
1142
|
-
schema: '2.0',
|
|
1143
|
-
config: { wide_screen_mode: true },
|
|
1144
|
-
header: {
|
|
1145
|
-
template: 'green',
|
|
1146
|
-
title: {
|
|
1147
|
-
tag: 'plain_text',
|
|
1148
|
-
content: '✅ 会话已完成',
|
|
1149
|
-
},
|
|
1150
|
-
},
|
|
1151
|
-
body: { elements },
|
|
1152
|
-
};
|
|
1153
1399
|
// P1-21: send through sendResult so enforceCardBudget guards the card
|
|
1154
1400
|
// (previously sent via connector directly, bypassing the budget — long
|
|
1155
1401
|
// sessions produced >28KB cards that Feishu silently rejected).
|
|
@@ -1165,6 +1411,264 @@ export class Bridge {
|
|
|
1165
1411
|
getLogger().warn(`[bridge] failed to send completion notification card:`, err);
|
|
1166
1412
|
}
|
|
1167
1413
|
}
|
|
1414
|
+
// =============================================================================
|
|
1415
|
+
// Approval action handlers
|
|
1416
|
+
// =============================================================================
|
|
1417
|
+
/**
|
|
1418
|
+
* Resolve the ApprovalCoordinator for a run, or throw the standard
|
|
1419
|
+
* "已结束" error. Shared preamble of all approval card-action handlers.
|
|
1420
|
+
*/
|
|
1421
|
+
withCoordinator(runId, fn) {
|
|
1422
|
+
const coordinator = this.approvalCoordinators.get(runId);
|
|
1423
|
+
if (!coordinator) {
|
|
1424
|
+
// P2-7: run 已结束(coordinator 已释放)时点击审批按钮必须给用户明确反馈,
|
|
1425
|
+
// 不能静默。router 会把该异常转成 error toast。
|
|
1426
|
+
getLogger().warn(`[bridge] no approval coordinator for runId=${runId.slice(0, 8)}`);
|
|
1427
|
+
throw new Error('任务已结束,审批无法响应');
|
|
1428
|
+
}
|
|
1429
|
+
return fn(coordinator);
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Handle an approval response from a card button click.
|
|
1433
|
+
* Routes to the ApprovalCoordinator for the given run.
|
|
1434
|
+
*/
|
|
1435
|
+
async handleApprovalRespond(opts) {
|
|
1436
|
+
const log = getLogger();
|
|
1437
|
+
log.info(`[bridge] handleApprovalRespond runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} decision=${opts.decision}`);
|
|
1438
|
+
return this.withCoordinator(opts.runId, async (coordinator) => {
|
|
1439
|
+
// Build ApprovalAction from the decision string
|
|
1440
|
+
const action = decisionToApprovalAction(opts.decision);
|
|
1441
|
+
await coordinator.submit(action, { requestId: opts.requestId, nonce: opts.nonce });
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Handle a permission toggle from a card button click.
|
|
1446
|
+
* Routes to the ApprovalCoordinator for the given run.
|
|
1447
|
+
*/
|
|
1448
|
+
async handleApprovalToggle(opts) {
|
|
1449
|
+
const log = getLogger();
|
|
1450
|
+
log.info(`[bridge] handleApprovalToggle runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} permId=${opts.permId}`);
|
|
1451
|
+
return this.withCoordinator(opts.runId, async (coordinator) => {
|
|
1452
|
+
// selected 由卡片根据当前渲染状态传回(取反),不再硬编码只授不撤。
|
|
1453
|
+
const toggleAction = {
|
|
1454
|
+
permId: opts.permId,
|
|
1455
|
+
selected: opts.selected ?? true,
|
|
1456
|
+
};
|
|
1457
|
+
await coordinator.togglePerm(toggleAction, { requestId: opts.requestId });
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Claude AskUserQuestion 选项点击(单选即时提交 / 多选切换勾选)。
|
|
1462
|
+
* 路由到 ApprovalCoordinator.toggleAnswer。
|
|
1463
|
+
*/
|
|
1464
|
+
async handleApprovalAnswer(opts) {
|
|
1465
|
+
const log = getLogger();
|
|
1466
|
+
log.info(`[bridge] handleApprovalAnswer runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
|
|
1467
|
+
return this.withCoordinator(opts.runId, (coordinator) => coordinator.toggleAnswer({ questionIndex: opts.questionIndex, option: opts.option }, { requestId: opts.requestId, nonce: opts.nonce }));
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Claude AskUserQuestion 多选问题的「提交答案」按钮。
|
|
1471
|
+
* 路由到 ApprovalCoordinator.submitAnswers。
|
|
1472
|
+
*/
|
|
1473
|
+
async handleApprovalAnswerSubmit(opts) {
|
|
1474
|
+
const log = getLogger();
|
|
1475
|
+
log.info(`[bridge] handleApprovalAnswerSubmit runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
|
|
1476
|
+
return this.withCoordinator(opts.runId, (coordinator) => coordinator.submitAnswers({ questionIndex: opts.questionIndex }, { requestId: opts.requestId, nonce: opts.nonce }));
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Claude AskUserQuestion 自定义答案(Other,review P3-4):自由文本直接
|
|
1480
|
+
* 作为该单选问题的答案提交。路由到 ApprovalCoordinator.answerCustom。
|
|
1481
|
+
*/
|
|
1482
|
+
async handleApprovalAnswerCustom(opts) {
|
|
1483
|
+
const log = getLogger();
|
|
1484
|
+
log.info(`[bridge] handleApprovalAnswerCustom runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
|
|
1485
|
+
return this.withCoordinator(opts.runId, (coordinator) => coordinator.answerCustom({ questionIndex: opts.questionIndex, text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce }));
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Handle codex.compact card action — trigger a Codex compaction request.
|
|
1489
|
+
* Validates the run exists and is in a terminal state, then calls
|
|
1490
|
+
* the app-server runner's runCompact().
|
|
1491
|
+
*/
|
|
1492
|
+
async handleCodexCompact(value, ctx) {
|
|
1493
|
+
const log = getLogger();
|
|
1494
|
+
log.info(`[bridge] handleCodexCompact userId=${ctx.userId} runId=${value.runId?.slice(0, 8)}...`);
|
|
1495
|
+
const { runId } = value;
|
|
1496
|
+
if (!runId) {
|
|
1497
|
+
await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const cwd = this.resolveCwd(ctx.userId);
|
|
1501
|
+
if (!cwd) {
|
|
1502
|
+
await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
// 校验:run 结束后 activeRuns 已清空,用 lastCompactableCodexRun 对照 runId。
|
|
1506
|
+
const last = this.lastCompactableCodexRun.get(cwd);
|
|
1507
|
+
if (!last || last.runId !== runId) {
|
|
1508
|
+
await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (!last.sessionId) {
|
|
1512
|
+
await this.sendResult({ text: '⚠️ 未找到可压缩的会话' }, ctx);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
// Check that the runner has runCompact method
|
|
1516
|
+
const runner = this.getRunner(cwd, last.agentKind);
|
|
1517
|
+
if (!('runCompact' in runner) ||
|
|
1518
|
+
typeof runner.runCompact !== 'function') {
|
|
1519
|
+
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
log.info(`[bridge] handleCodexCompact executing runCompact for runId=${runId} cwd=${cwd}`);
|
|
1523
|
+
await this.streamCodexCompact({
|
|
1524
|
+
sessionId: last.sessionId,
|
|
1525
|
+
cwd,
|
|
1526
|
+
agentKind: last.agentKind,
|
|
1527
|
+
runner,
|
|
1528
|
+
ctx,
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Stream a Codex compaction to a card: start a RunCardSession with
|
|
1533
|
+
* operationKind='compaction' (no recursive Compact button), consume
|
|
1534
|
+
* runner.runCompact() events, read authoritative jsonl usage, and finish
|
|
1535
|
+
* the card. Shared by handleCodexCompact (run card button) and
|
|
1536
|
+
* handleResumeCompact (resume cards) so both flows stay in sync.
|
|
1537
|
+
*
|
|
1538
|
+
* Precondition: callers have already validated that `runner` implements
|
|
1539
|
+
* runCompact and that the session exists in `cwd`.
|
|
1540
|
+
*/
|
|
1541
|
+
async streamCodexCompact(opts) {
|
|
1542
|
+
const log = getLogger();
|
|
1543
|
+
const { sessionId, cwd, agentKind, runner, ctx } = opts;
|
|
1544
|
+
log.info(`[bridge] streamCodexCompact sessionId=${sessionId} cwd=${cwd}`);
|
|
1545
|
+
// 防御性能力检查:调用方已校验(runId / resume 两条路径),此处双保险并
|
|
1546
|
+
// 让 TS 收窄 runner 类型('runCompact' in runner 之后的 cast 才合法)。
|
|
1547
|
+
if (!('runCompact' in runner) ||
|
|
1548
|
+
typeof runner.runCompact !== 'function') {
|
|
1549
|
+
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
|
|
1553
|
+
const compactRunId = randomUUID();
|
|
1554
|
+
const cardSession = new RunCardSession({
|
|
1555
|
+
connector: this.connector,
|
|
1556
|
+
chatId: ctx.chatId,
|
|
1557
|
+
replyTo: ctx.messageId,
|
|
1558
|
+
runId: compactRunId,
|
|
1559
|
+
renderOptions: {
|
|
1560
|
+
showThinking: this.config.output.showThinking,
|
|
1561
|
+
showToolUse: this.config.output.showToolUse,
|
|
1562
|
+
showToolResult: this.config.output.showToolResult,
|
|
1563
|
+
agentKind,
|
|
1564
|
+
},
|
|
1565
|
+
});
|
|
1566
|
+
try {
|
|
1567
|
+
await cardSession.start();
|
|
1568
|
+
// operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
|
|
1569
|
+
await cardSession.push({
|
|
1570
|
+
type: 'turn_started',
|
|
1571
|
+
threadId: sessionId,
|
|
1572
|
+
turnId: '',
|
|
1573
|
+
operationKind: 'compaction',
|
|
1574
|
+
});
|
|
1575
|
+
let sawResult = false;
|
|
1576
|
+
for await (const event of runner.runCompact('', { cwd, sessionId })) {
|
|
1577
|
+
if (event.type === 'result')
|
|
1578
|
+
sawResult = true;
|
|
1579
|
+
await cardSession.push(event);
|
|
1580
|
+
}
|
|
1581
|
+
// 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
|
|
1582
|
+
// token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
|
|
1583
|
+
// (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
|
|
1584
|
+
let finalUsage = this.resolveFinalUsage(sessionId, cwd, agentKind);
|
|
1585
|
+
for (let attempt = 0; !finalUsage?.compactCount && attempt < 2; attempt++) {
|
|
1586
|
+
await sleep(150);
|
|
1587
|
+
finalUsage = this.resolveFinalUsage(sessionId, cwd, agentKind);
|
|
1588
|
+
}
|
|
1589
|
+
await cardSession.finish(sawResult ? 'done' : 'error', {
|
|
1590
|
+
resultSubtype: sawResult ? 'success' : 'error',
|
|
1591
|
+
errorMsg: sawResult ? undefined : '未收到压缩结果',
|
|
1592
|
+
contextLength: finalUsage?.contextLength,
|
|
1593
|
+
contextLimit: finalUsage?.contextLimit,
|
|
1594
|
+
compactPreContextLength: finalUsage?.compactPreContextLength,
|
|
1595
|
+
compactCount: finalUsage?.compactCount,
|
|
1596
|
+
cacheReadTokens: finalUsage?.cacheReadTokens,
|
|
1597
|
+
cacheCreationTokens: finalUsage?.cacheCreationTokens,
|
|
1598
|
+
totalTokens: finalUsage?.totalTokens,
|
|
1599
|
+
inputTokens: finalUsage?.inputTokens,
|
|
1600
|
+
outputTokens: finalUsage?.outputTokens,
|
|
1601
|
+
cumulativeTotalTokens: finalUsage?.cumulativeTotalTokens,
|
|
1602
|
+
cumulativeInputTokens: finalUsage?.cumulativeInputTokens,
|
|
1603
|
+
cumulativeOutputTokens: finalUsage?.cumulativeOutputTokens,
|
|
1604
|
+
cumulativeCacheReadTokens: finalUsage?.cumulativeCacheReadTokens,
|
|
1605
|
+
cumulativeCacheCreationTokens: finalUsage?.cumulativeCacheCreationTokens,
|
|
1606
|
+
});
|
|
1607
|
+
log.info(`[bridge] streamCodexCompact finished sessionId=${sessionId} sawResult=${sawResult}`);
|
|
1608
|
+
}
|
|
1609
|
+
catch (err) {
|
|
1610
|
+
log.error(`[bridge] streamCodexCompact failed: ${errorMessage(err)}`);
|
|
1611
|
+
await cardSession.finish('error', { errorMsg: errorMessage(err) });
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Handle resume.compact card action — compact a session directly from a
|
|
1616
|
+
* resume card (auto-resume / `/resume <id>`), without a runId.
|
|
1617
|
+
*
|
|
1618
|
+
* Unlike handleCodexCompact (which validates against the last finished run's
|
|
1619
|
+
* runId), the resume card carries the sessionId + agent it was rendered for.
|
|
1620
|
+
* Validation: the session must exist in the current cwd (same rule as
|
|
1621
|
+
* cmdResume / resume.use) and the runner must implement runCompact.
|
|
1622
|
+
*/
|
|
1623
|
+
async handleResumeCompact(value, ctx) {
|
|
1624
|
+
const log = getLogger();
|
|
1625
|
+
log.info(`[bridge] handleResumeCompact userId=${ctx.userId} sessionId=${value.sessionId?.slice(0, 16)} agent=${value.agent ?? 'default'}`);
|
|
1626
|
+
const { sessionId } = value;
|
|
1627
|
+
if (!sessionId) {
|
|
1628
|
+
await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 sessionId' }, ctx);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const cwd = this.resolveCwd(ctx.userId);
|
|
1632
|
+
if (!cwd) {
|
|
1633
|
+
await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
// Resolve agent kind the same way resume.use does: card value wins,
|
|
1637
|
+
// otherwise fall back to defaultAgent.
|
|
1638
|
+
const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
|
|
1639
|
+
const agentKind = value.agent && validAgents.includes(value.agent)
|
|
1640
|
+
? value.agent
|
|
1641
|
+
: this.config.defaultAgent;
|
|
1642
|
+
// Session must exist in the current cwd (same rule as cmdResume /
|
|
1643
|
+
// resume.use): reject ghosts so a stale card cannot compact a session
|
|
1644
|
+
// from another cwd. Read failures get visible feedback (card click
|
|
1645
|
+
// 红线:不许静默失败)。
|
|
1646
|
+
let content;
|
|
1647
|
+
try {
|
|
1648
|
+
content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
|
|
1649
|
+
}
|
|
1650
|
+
catch (err) {
|
|
1651
|
+
await this.sendResult({ text: `⚠️ 读取 session ${sessionId} 失败: ${errorMessage(err)}` }, ctx);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (content.events.length === 0 &&
|
|
1655
|
+
!content.usage &&
|
|
1656
|
+
!content.aiTitle &&
|
|
1657
|
+
!content.recap &&
|
|
1658
|
+
!content.displayTitle) {
|
|
1659
|
+
await this.sendResult({ text: `⚠️ 未找到 session ${sessionId}(当前目录: ${cwd})` }, ctx);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
// Check that the runner has runCompact (codex app-server only).
|
|
1663
|
+
const runner = this.getRunner(cwd, agentKind);
|
|
1664
|
+
if (!('runCompact' in runner) ||
|
|
1665
|
+
typeof runner.runCompact !== 'function') {
|
|
1666
|
+
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
log.info(`[bridge] handleResumeCompact executing runCompact sessionId=${sessionId} cwd=${cwd}`);
|
|
1670
|
+
await this.streamCodexCompact({ sessionId, cwd, agentKind, runner, ctx });
|
|
1671
|
+
}
|
|
1168
1672
|
/**
|
|
1169
1673
|
* Execute a bash command and stream output to a card.
|
|
1170
1674
|
* `!` commands bypass the serial queue entirely (see body comment + §9.6):
|