lark-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +162 -0
- package/README.md +161 -0
- package/dist/binder.d.ts +57 -0
- package/dist/binder.js +105 -0
- package/dist/bridge/index.d.ts +338 -0
- package/dist/bridge/index.js +1281 -0
- package/dist/bridge/queue-manager.d.ts +277 -0
- package/dist/bridge/queue-manager.js +729 -0
- package/dist/card/bash-card-session.d.ts +78 -0
- package/dist/card/bash-card-session.js +174 -0
- package/dist/card/bash-renderer.d.ts +24 -0
- package/dist/card/bash-renderer.js +187 -0
- package/dist/card/card-budget.d.ts +31 -0
- package/dist/card/card-budget.js +280 -0
- package/dist/card/card-session.d.ts +83 -0
- package/dist/card/card-session.js +185 -0
- package/dist/card/card-shared.d.ts +28 -0
- package/dist/card/card-shared.js +99 -0
- package/dist/card/collapsible.d.ts +26 -0
- package/dist/card/collapsible.js +87 -0
- package/dist/card/run-card-session.d.ts +78 -0
- package/dist/card/run-card-session.js +165 -0
- package/dist/card/run-renderer.d.ts +66 -0
- package/dist/card/run-renderer.js +841 -0
- package/dist/card/run-state.d.ts +103 -0
- package/dist/card/run-state.js +313 -0
- package/dist/card/text-truncate.d.ts +56 -0
- package/dist/card/text-truncate.js +184 -0
- package/dist/card/time.d.ts +1 -0
- package/dist/card/time.js +14 -0
- package/dist/card/tool-render.d.ts +25 -0
- package/dist/card/tool-render.js +176 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.js +89 -0
- package/dist/common/guards.d.ts +37 -0
- package/dist/common/guards.js +65 -0
- package/dist/config/codex-config.d.ts +113 -0
- package/dist/config/codex-config.js +428 -0
- package/dist/config/dir.d.ts +17 -0
- package/dist/config/dir.js +81 -0
- package/dist/config/index.d.ts +219 -0
- package/dist/config/index.js +388 -0
- package/dist/config/kimi-config.d.ts +33 -0
- package/dist/config/kimi-config.js +111 -0
- package/dist/config/opencode-config.d.ts +20 -0
- package/dist/config/opencode-config.js +124 -0
- package/dist/config/pi-config.d.ts +49 -0
- package/dist/config/pi-config.js +151 -0
- package/dist/config/wizard.d.ts +12 -0
- package/dist/config/wizard.js +84 -0
- package/dist/connector/file-limits.d.ts +7 -0
- package/dist/connector/file-limits.js +8 -0
- package/dist/connector/index.d.ts +86 -0
- package/dist/connector/index.js +340 -0
- package/dist/error-classification.d.ts +15 -0
- package/dist/error-classification.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +580 -0
- package/dist/instance-lock.d.ts +30 -0
- package/dist/instance-lock.js +184 -0
- package/dist/logger/index.d.ts +57 -0
- package/dist/logger/index.js +178 -0
- package/dist/order/index.d.ts +27 -0
- package/dist/order/index.js +90 -0
- package/dist/persistence/atomic-write.d.ts +10 -0
- package/dist/persistence/atomic-write.js +65 -0
- package/dist/persistence/load-json-file.d.ts +9 -0
- package/dist/persistence/load-json-file.js +23 -0
- package/dist/restart.d.ts +24 -0
- package/dist/restart.js +73 -0
- package/dist/router/card-helpers.d.ts +28 -0
- package/dist/router/card-helpers.js +65 -0
- package/dist/router/config/claude.d.ts +14 -0
- package/dist/router/config/claude.js +72 -0
- package/dist/router/config/codex.d.ts +22 -0
- package/dist/router/config/codex.js +100 -0
- package/dist/router/config/common/render.d.ts +16 -0
- package/dist/router/config/common/render.js +158 -0
- package/dist/router/config/index.d.ts +18 -0
- package/dist/router/config/index.js +38 -0
- package/dist/router/config/kimi.d.ts +21 -0
- package/dist/router/config/kimi.js +63 -0
- package/dist/router/config/opencode.d.ts +12 -0
- package/dist/router/config/opencode.js +44 -0
- package/dist/router/config/pi.d.ts +12 -0
- package/dist/router/config/pi.js +50 -0
- package/dist/router/config/types.d.ts +43 -0
- package/dist/router/config/types.js +8 -0
- package/dist/router/index.d.ts +318 -0
- package/dist/router/index.js +2715 -0
- package/dist/router/order-exec-dispatch.d.ts +30 -0
- package/dist/router/order-exec-dispatch.js +48 -0
- package/dist/router/utils.d.ts +44 -0
- package/dist/router/utils.js +116 -0
- package/dist/runner/bash/index.d.ts +4 -0
- package/dist/runner/bash/index.js +5 -0
- package/dist/runner/bash/runner.d.ts +55 -0
- package/dist/runner/bash/runner.js +213 -0
- package/dist/runner/claude/index.d.ts +4 -0
- package/dist/runner/claude/index.js +5 -0
- package/dist/runner/claude/runner.d.ts +29 -0
- package/dist/runner/claude/runner.js +83 -0
- package/dist/runner/codex/argv.d.ts +33 -0
- package/dist/runner/codex/argv.js +40 -0
- package/dist/runner/codex/index.d.ts +1 -0
- package/dist/runner/codex/index.js +2 -0
- package/dist/runner/codex/jsonl.d.ts +37 -0
- package/dist/runner/codex/jsonl.js +221 -0
- package/dist/runner/codex/runner.d.ts +65 -0
- package/dist/runner/codex/runner.js +124 -0
- package/dist/runner/common/exec-translator.d.ts +73 -0
- package/dist/runner/common/exec-translator.js +105 -0
- package/dist/runner/common/jsonl-stream.d.ts +39 -0
- package/dist/runner/common/jsonl-stream.js +203 -0
- package/dist/runner/common/process-stopper.d.ts +28 -0
- package/dist/runner/common/process-stopper.js +79 -0
- package/dist/runner/common/runner-utils.d.ts +21 -0
- package/dist/runner/common/runner-utils.js +65 -0
- package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
- package/dist/runner/common/spawn-heartbeat.js +51 -0
- package/dist/runner/common/spawning-runner.d.ts +321 -0
- package/dist/runner/common/spawning-runner.js +634 -0
- package/dist/runner/common/usage.d.ts +31 -0
- package/dist/runner/common/usage.js +29 -0
- package/dist/runner/index.d.ts +19 -0
- package/dist/runner/index.js +24 -0
- package/dist/runner/kimi/index.d.ts +1 -0
- package/dist/runner/kimi/index.js +2 -0
- package/dist/runner/kimi/jsonl.d.ts +28 -0
- package/dist/runner/kimi/jsonl.js +143 -0
- package/dist/runner/kimi/runner.d.ts +50 -0
- package/dist/runner/kimi/runner.js +105 -0
- package/dist/runner/opencode/argv.d.ts +32 -0
- package/dist/runner/opencode/argv.js +35 -0
- package/dist/runner/opencode/index.d.ts +1 -0
- package/dist/runner/opencode/index.js +2 -0
- package/dist/runner/opencode/jsonl.d.ts +68 -0
- package/dist/runner/opencode/jsonl.js +295 -0
- package/dist/runner/opencode/runner.d.ts +63 -0
- package/dist/runner/opencode/runner.js +93 -0
- package/dist/runner/pi/index.d.ts +1 -0
- package/dist/runner/pi/index.js +2 -0
- package/dist/runner/pi/jsonl.d.ts +128 -0
- package/dist/runner/pi/jsonl.js +249 -0
- package/dist/runner/pi/runner.d.ts +37 -0
- package/dist/runner/pi/runner.js +76 -0
- package/dist/runner/registry.d.ts +54 -0
- package/dist/runner/registry.js +75 -0
- package/dist/runner/resolve-agent-choices.d.ts +7 -0
- package/dist/runner/resolve-agent-choices.js +76 -0
- package/dist/runner/sync-agent-choices.d.ts +6 -0
- package/dist/runner/sync-agent-choices.js +73 -0
- package/dist/runner/types.d.ts +250 -0
- package/dist/runner/types.js +8 -0
- package/dist/session/claude/index.d.ts +1 -0
- package/dist/session/claude/index.js +2 -0
- package/dist/session/claude/session-reader.d.ts +34 -0
- package/dist/session/claude/session-reader.js +46 -0
- package/dist/session/claude/sessions.d.ts +53 -0
- package/dist/session/claude/sessions.js +528 -0
- package/dist/session/codex/index.d.ts +1 -0
- package/dist/session/codex/index.js +2 -0
- package/dist/session/codex/rollout-reader.d.ts +88 -0
- package/dist/session/codex/rollout-reader.js +473 -0
- package/dist/session/codex/sessions.d.ts +21 -0
- package/dist/session/codex/sessions.js +40 -0
- package/dist/session/common/catch-up.d.ts +12 -0
- package/dist/session/common/catch-up.js +31 -0
- package/dist/session/common/constants.d.ts +11 -0
- package/dist/session/common/constants.js +12 -0
- package/dist/session/common/content-blocks.d.ts +37 -0
- package/dist/session/common/content-blocks.js +68 -0
- package/dist/session/common/jsonl.d.ts +77 -0
- package/dist/session/common/jsonl.js +351 -0
- package/dist/session/common/usage-accumulator.d.ts +58 -0
- package/dist/session/common/usage-accumulator.js +72 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +11 -0
- package/dist/session/kimi/index.d.ts +1 -0
- package/dist/session/kimi/index.js +2 -0
- package/dist/session/kimi/sessions.d.ts +81 -0
- package/dist/session/kimi/sessions.js +527 -0
- package/dist/session/opencode/index.d.ts +1 -0
- package/dist/session/opencode/index.js +2 -0
- package/dist/session/opencode/sessions.d.ts +81 -0
- package/dist/session/opencode/sessions.js +388 -0
- package/dist/session/pi/index.d.ts +1 -0
- package/dist/session/pi/index.js +2 -0
- package/dist/session/pi/sessions.d.ts +27 -0
- package/dist/session/pi/sessions.js +399 -0
- package/dist/session/registry.d.ts +12 -0
- package/dist/session/registry.js +20 -0
- package/dist/session/session-store.d.ts +86 -0
- package/dist/session/session-store.js +450 -0
- package/dist/startup-contact.d.ts +18 -0
- package/dist/startup-contact.js +51 -0
- package/dist/workspace/index.d.ts +19 -0
- package/dist/workspace/index.js +53 -0
- package/package.json +93 -0
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getLogger } from '../logger/index.js';
|
|
3
|
+
import { RunCardSession } from '../card/run-card-session.js';
|
|
4
|
+
import { renderRunCard } from '../card/run-renderer.js';
|
|
5
|
+
import { renderBashCard } from '../card/bash-renderer.js';
|
|
6
|
+
import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
|
|
7
|
+
import { markdownDiv } from '../card/collapsible.js';
|
|
8
|
+
import { sessionEventPanel } from '../router/card-helpers.js';
|
|
9
|
+
import { formatUsageStats } from '../router/index.js';
|
|
10
|
+
import { agentDisplayName } from '../card/card-shared.js';
|
|
11
|
+
import { enforceCardBudget } from '../card/card-budget.js';
|
|
12
|
+
import { normalizeResultUsage } from '../runner/common/usage.js';
|
|
13
|
+
import { BashProcessRunner } from '../runner/index.js';
|
|
14
|
+
import { QueueManager, } from './queue-manager.js';
|
|
15
|
+
/**
|
|
16
|
+
* Max events to read for the completion notification card (mirror router's
|
|
17
|
+
* AUTO_RESUME_MAX_EVENTS = 5). Without a cap, readSessionContent returns every
|
|
18
|
+
* event after the last user message and the card blows past Feishu's 28KB
|
|
19
|
+
* budget (review §P1-21).
|
|
20
|
+
*/
|
|
21
|
+
const COMPLETION_NOTIFICATION_MAX_EVENTS = 5;
|
|
22
|
+
/**
|
|
23
|
+
* Max idle time (no stdout events) before a claude run is considered hung
|
|
24
|
+
* (§9.12). When this fires the bridge calls `runner.stop()` to unblock the
|
|
25
|
+
* serial queue — otherwise a single hung run would block every subsequent
|
|
26
|
+
* message forever.
|
|
27
|
+
*/
|
|
28
|
+
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
29
|
+
/**
|
|
30
|
+
* Emoji reaction per run terminal state, so a glance at the user's original
|
|
31
|
+
* message distinguishes how the run ended. Unknown/non-terminal states fall
|
|
32
|
+
* back to 'Done' (default).
|
|
33
|
+
*/
|
|
34
|
+
const TERMINAL_REACTION_EMOJI = {
|
|
35
|
+
error: 'ERROR',
|
|
36
|
+
idle_timeout: 'Alarm',
|
|
37
|
+
interrupted: 'SHHH',
|
|
38
|
+
};
|
|
39
|
+
function terminalReactionEmoji(terminal) {
|
|
40
|
+
return TERMINAL_REACTION_EMOJI[terminal] ?? 'Done';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Bridge owns the per-workspace serial processing queue (§9.6) and the
|
|
44
|
+
* single-card streaming forwarding loop (§9.12 watchdog + session sync).
|
|
45
|
+
*
|
|
46
|
+
* The router delegates non-command messages here via `forwardToClaude` and
|
|
47
|
+
* sends command results via `sendResult`. Serial processing is enforced per
|
|
48
|
+
* workspace by `enqueue`: each workspace has its own Promise chain, so at
|
|
49
|
+
* most one agent process runs per workspace at a time, while different
|
|
50
|
+
* workspaces run in parallel (see `activeRuns`, §9.6). This is a load-bearing
|
|
51
|
+
* invariant — concurrent spawns within one workspace would corrupt session
|
|
52
|
+
* state. Most card actions go through `enqueueImmediate` (fire-and-forget,
|
|
53
|
+
* outside the serial chain); only run-forwarding is serialized.
|
|
54
|
+
*
|
|
55
|
+
* The idle watchdog (§9.12) is also load-bearing: without it a hung agent
|
|
56
|
+
* process would block its workspace's serial queue forever. Both invariants
|
|
57
|
+
* now have a single owner (this class) and a test surface via the
|
|
58
|
+
* Runner/BridgeChannel seams.
|
|
59
|
+
*/
|
|
60
|
+
export class Bridge {
|
|
61
|
+
runner;
|
|
62
|
+
connector;
|
|
63
|
+
sessionStore;
|
|
64
|
+
config;
|
|
65
|
+
workspaceStore;
|
|
66
|
+
idleTimeoutMs;
|
|
67
|
+
/** Multi-agent registry. */
|
|
68
|
+
agentRegistry;
|
|
69
|
+
/** Session reader registry for usage and completion cards. */
|
|
70
|
+
sessionReaderRegistry;
|
|
71
|
+
/**
|
|
72
|
+
* Per-workspace runner instances, keyed by (cwd, agentKind).
|
|
73
|
+
* Fix 4 (2026-07-18): Each agentKind has its own cache slot, so switching
|
|
74
|
+
* defaultAgent creates a NEW runner without evicting the old one. This
|
|
75
|
+
* eliminates the need for clearRunners() on agent switch, preventing the
|
|
76
|
+
* regression where clearRunners() orphaned a running subprocess and
|
|
77
|
+
* blocked the queue forever.
|
|
78
|
+
*/
|
|
79
|
+
runners = new Map();
|
|
80
|
+
/** Queue manager for per-workspace serial processing queue. */
|
|
81
|
+
queueManager;
|
|
82
|
+
/** Active runs per workspace (cwd). Multiple workspaces can run in parallel. */
|
|
83
|
+
activeRuns = new Map();
|
|
84
|
+
/**
|
|
85
|
+
* Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
|
|
86
|
+
* queue and may run in parallel with claude runs / other bash runs in the same
|
|
87
|
+
* workspace, so they live outside `activeRuns`. Tracked so `/stop` can reach
|
|
88
|
+
* the per-run BashRunner instance via interruptCurrentRun.
|
|
89
|
+
*/
|
|
90
|
+
activeBashRuns = new Map();
|
|
91
|
+
constructor(deps) {
|
|
92
|
+
this.runner = deps.runner;
|
|
93
|
+
this.connector = deps.connector;
|
|
94
|
+
this.sessionStore = deps.sessionStore;
|
|
95
|
+
this.config = deps.config;
|
|
96
|
+
this.workspaceStore = deps.workspaceStore;
|
|
97
|
+
this.idleTimeoutMs = deps.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
|
|
98
|
+
this.agentRegistry = deps.agentRegistry;
|
|
99
|
+
this.sessionReaderRegistry = deps.sessionReaderRegistry;
|
|
100
|
+
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));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get or create a runner for the given workspace, keyed by current defaultAgent.
|
|
104
|
+
*
|
|
105
|
+
* Fix 4 (2026-07-18): Cache key includes agentKind, so switching defaultAgent
|
|
106
|
+
* creates a NEW runner under a different key without evicting the old one.
|
|
107
|
+
* This eliminates the need for clearRunners() on agent switch.
|
|
108
|
+
*/
|
|
109
|
+
getRunner(workspace, kind = this.config.defaultAgent) {
|
|
110
|
+
// Fix 4: Check for runner under (workspace, kind) key
|
|
111
|
+
const workspaceMap = this.runners.get(workspace);
|
|
112
|
+
if (workspaceMap?.has(kind)) {
|
|
113
|
+
return workspaceMap.get(kind);
|
|
114
|
+
}
|
|
115
|
+
// Create new runner for this (workspace, kind) slot
|
|
116
|
+
const runner = this.agentRegistry.get(kind, workspace);
|
|
117
|
+
// Call lifecycle methods for agent runners created via factory
|
|
118
|
+
if (runner && typeof runner.killOrphan === 'function') {
|
|
119
|
+
runner.killOrphan();
|
|
120
|
+
}
|
|
121
|
+
if (runner && typeof runner.registerExitHandlers === 'function') {
|
|
122
|
+
runner.registerExitHandlers();
|
|
123
|
+
}
|
|
124
|
+
// Store in nested map: Map<workspace, Map<kind, Runner>>
|
|
125
|
+
if (!workspaceMap) {
|
|
126
|
+
this.runners.set(workspace, new Map([[kind, runner]]));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
workspaceMap.set(kind, runner);
|
|
130
|
+
}
|
|
131
|
+
return runner;
|
|
132
|
+
}
|
|
133
|
+
/** Update the config reference (call after `/config set` reloads). */
|
|
134
|
+
setConfig(config) {
|
|
135
|
+
this.config = config;
|
|
136
|
+
// 同步更新 registry 的 configContainer,让 factory 能读取最新 config
|
|
137
|
+
const container = this.agentRegistry.getConfigContainer();
|
|
138
|
+
if (container) {
|
|
139
|
+
container.current = config;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Get the current runner for a workspace. Used by router for agent-specific commands (P3.2). */
|
|
143
|
+
getCurrentRunner(workspace) {
|
|
144
|
+
return this.getRunner(workspace);
|
|
145
|
+
}
|
|
146
|
+
/** 入队时刻快照:当前 defaultAgent + 该 agent 的 sessionId(无 session 则 undefined)。
|
|
147
|
+
* 唯一捕获点:排队消息在 T0 把 agent+session 钉进 AgentBinding,随任务闭包带到 T1
|
|
148
|
+
* 执行时刻,避免 /new、/config 在排队期间改写 live 状态导致语义漂移(方案 D4)。 */
|
|
149
|
+
currentBinding(userId) {
|
|
150
|
+
const agent = this.config.defaultAgent;
|
|
151
|
+
return { agent, sessionId: this.sessionStore.getSessionId(userId, agent) };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Clear all cached runner instances. Call after config changes that affect runner config.
|
|
155
|
+
*/
|
|
156
|
+
clearRunners() {
|
|
157
|
+
// Skip workspaces with an active run: evicting their runner would orphan
|
|
158
|
+
// the instance that owns the live subprocess (regression 2026-07-18 -- the
|
|
159
|
+
// running runner became unreachable, stop() hit a fresh empty runner, the
|
|
160
|
+
// real subprocess was never killed and the queue chain blocked forever).
|
|
161
|
+
// The running run already started with its (possibly now-stale) config, so
|
|
162
|
+
// keeping it is correct; finalizeRun deletes the cache entry when the run
|
|
163
|
+
// ends, and the next run picks up the new config/agent via getRunner.
|
|
164
|
+
for (const cwd of [...this.runners.keys()]) {
|
|
165
|
+
if (this.activeRuns.has(cwd))
|
|
166
|
+
continue;
|
|
167
|
+
for (const kind of [...this.runners.get(cwd).keys()]) {
|
|
168
|
+
this.evictRunnerSlot(cwd, kind);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Evict a (cwd, agentKind) runner cache slot. Unregisters the runner from the
|
|
174
|
+
* process-level exit dispatcher first (P1-1: without removal the singleton
|
|
175
|
+
* Set<Runner> retains every historical runner instance, so memory still grows
|
|
176
|
+
* with run count), then deletes the slot. Every runner-discarding path
|
|
177
|
+
* (clearRunners / finalizeRun / interruptCurrentRun) must go through this.
|
|
178
|
+
*/
|
|
179
|
+
evictRunnerSlot(cwd, kind) {
|
|
180
|
+
const workspaceMap = this.runners.get(cwd);
|
|
181
|
+
if (!workspaceMap)
|
|
182
|
+
return;
|
|
183
|
+
const evicted = workspaceMap.get(kind);
|
|
184
|
+
if (evicted && typeof evicted.unregisterExitHandlers === 'function') {
|
|
185
|
+
evicted.unregisterExitHandlers();
|
|
186
|
+
}
|
|
187
|
+
workspaceMap.delete(kind);
|
|
188
|
+
if (workspaceMap.size === 0) {
|
|
189
|
+
this.runners.delete(cwd);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Update the idle watchdog window at runtime.
|
|
194
|
+
* Called by router constructor and `config.save` (when `idle.watchdogMinutes` changes
|
|
195
|
+
* via `/config` card). The idle interval reads `this.idleTimeoutMs` live on every
|
|
196
|
+
* tick, so the new threshold takes effect immediately for any in-flight run —
|
|
197
|
+
* there is no per-event timer to reset.
|
|
198
|
+
*/
|
|
199
|
+
setIdleTimeout(ms) {
|
|
200
|
+
this.idleTimeoutMs = ms;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Extract agent-specific run options from config.
|
|
204
|
+
* Returns model/effort/etc. based on current defaultAgent.
|
|
205
|
+
*/
|
|
206
|
+
getAgentRunOptions(agent = this.config.defaultAgent) {
|
|
207
|
+
const agents = this.config.agents;
|
|
208
|
+
const result = {};
|
|
209
|
+
switch (agent) {
|
|
210
|
+
case 'claude':
|
|
211
|
+
// Claude uses top-level claude config
|
|
212
|
+
if (this.config.claude) {
|
|
213
|
+
if (this.config.claude.model)
|
|
214
|
+
result.model = this.config.claude.model;
|
|
215
|
+
if (this.config.claude.effort)
|
|
216
|
+
result.effort = this.config.claude.effort;
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
case 'codex':
|
|
220
|
+
if (agents?.codex?.model)
|
|
221
|
+
result.model = agents.codex.model;
|
|
222
|
+
if (agents?.codex?.reasoningEffort)
|
|
223
|
+
result.reasoningEffort = agents.codex.reasoningEffort;
|
|
224
|
+
break;
|
|
225
|
+
case 'pi':
|
|
226
|
+
if (agents?.pi?.model)
|
|
227
|
+
result.model = agents.pi.model;
|
|
228
|
+
if (agents?.pi?.thinking)
|
|
229
|
+
result.thinking = agents.pi.thinking;
|
|
230
|
+
break;
|
|
231
|
+
case 'opencode':
|
|
232
|
+
// opencode model is set as provider/model in the constructor (defaultModel);
|
|
233
|
+
// returning bare modelID here would override it with a provider-less string.
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Enqueue a task into the workspace-level serial bridge queue.
|
|
240
|
+
* Delegates to QueueManager for actual implementation.
|
|
241
|
+
*/
|
|
242
|
+
enqueue(workspace, task, opts) {
|
|
243
|
+
this.queueManager.enqueue(workspace, task, opts);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Execute a task immediately without going through the queue.
|
|
247
|
+
* Used for / commands that should respond immediately.
|
|
248
|
+
*/
|
|
249
|
+
enqueueImmediate(workspace, task) {
|
|
250
|
+
this.queueManager.enqueueImmediate(workspace, task);
|
|
251
|
+
}
|
|
252
|
+
/** Get queue info for a workspace. */
|
|
253
|
+
getQueueInfo(workspace) {
|
|
254
|
+
return this.queueManager.getQueueInfo(workspace);
|
|
255
|
+
}
|
|
256
|
+
/** Remove a task from the queue by messageId. Returns true if found and removed. */
|
|
257
|
+
removeFromQueue(workspace, messageId) {
|
|
258
|
+
return this.queueManager.removeFromQueue(workspace, messageId);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* One-shot: replace the execution closure of an existing queued task in
|
|
262
|
+
* place, preserving its queue position. Used by queue.immediate after the
|
|
263
|
+
* user edits a queued message (see router handleQueueImmediate).
|
|
264
|
+
*/
|
|
265
|
+
setTaskReplacement(workspace, messageId, task) {
|
|
266
|
+
this.queueManager.setTaskReplacement(workspace, messageId, task);
|
|
267
|
+
}
|
|
268
|
+
/** Update queue card to "cancelled" status when user clicks 撤销. */
|
|
269
|
+
async updateQueueCardToCancelled(workspace, messageId) {
|
|
270
|
+
await this.queueManager.updateQueueCardToCancelled(workspace, messageId);
|
|
271
|
+
}
|
|
272
|
+
/** Mark a queued task's card as executing immediately (grey out buttons).
|
|
273
|
+
* Called by handleQueueImmediate so the user sees feedback right away
|
|
274
|
+
* instead of waiting for the queue callback. Idempotent: the queue
|
|
275
|
+
* callback's own updateQueueCardToExecuting call becomes a no-op because
|
|
276
|
+
* updateQueueCardToExecuting deletes the card-message mapping in finally. */
|
|
277
|
+
async markQueueCardExecuting(workspace, messageId) {
|
|
278
|
+
const task = this.getQueuedTask(workspace, messageId);
|
|
279
|
+
await this.queueManager.updateQueueCardToExecuting(workspace, messageId, task?.messagePreview ?? '', false);
|
|
280
|
+
}
|
|
281
|
+
/** Get task metadata from queue. */
|
|
282
|
+
getQueuedTask(workspace, messageId) {
|
|
283
|
+
return this.queueManager.getQueuedTask(workspace, messageId);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Whether a task with the given messageId has begun executing and has not
|
|
287
|
+
* settled yet. Lets handleQueueImmediate's final feedback distinguish a
|
|
288
|
+
* target that was cancelled from one that began while the interrupt was in
|
|
289
|
+
* flight (the latter is already running, so the toast must not claim
|
|
290
|
+
* "未安排执行").
|
|
291
|
+
*/
|
|
292
|
+
hasTaskBegan(messageId) {
|
|
293
|
+
return this.queueManager.hasBegan(messageId);
|
|
294
|
+
}
|
|
295
|
+
/** Get all queued tasks for a workspace. Returns a copy to prevent aliasing bugs. */
|
|
296
|
+
getQueuedTasks(workspace) {
|
|
297
|
+
return this.queueManager.getQueuedTasks(workspace);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Update the messagePreview for a queued task and build the updated queue
|
|
301
|
+
* card. Returns the card object to send back as the cardAction callback
|
|
302
|
+
* response (Feishu renders it in place), or null if the task is no longer
|
|
303
|
+
* queued. Returning the card via the callback response avoids the
|
|
304
|
+
* PATCH-vs-callback-response race that left the card stuck in edit state.
|
|
305
|
+
*/
|
|
306
|
+
async updateMessagePreview(workspace, messageId, newMessage) {
|
|
307
|
+
const updated = this.queueManager.updateQueuedTaskMessage(workspace, messageId, newMessage);
|
|
308
|
+
if (!updated)
|
|
309
|
+
return null;
|
|
310
|
+
return this.queueManager.buildQueueCardForEdit(workspace, messageId, newMessage);
|
|
311
|
+
}
|
|
312
|
+
/** Whether a claude run is currently in progress in the given workspace. */
|
|
313
|
+
isBusyFor(workspace) {
|
|
314
|
+
return this.activeRuns.has(workspace);
|
|
315
|
+
}
|
|
316
|
+
getActiveRunFor(workspace) {
|
|
317
|
+
const active = this.activeRuns.get(workspace);
|
|
318
|
+
if (!active)
|
|
319
|
+
return undefined;
|
|
320
|
+
const state = active.session.currentState;
|
|
321
|
+
return {
|
|
322
|
+
runId: active.runId,
|
|
323
|
+
userId: active.userId,
|
|
324
|
+
chatId: active.chatId,
|
|
325
|
+
cwd: active.cwd,
|
|
326
|
+
sessionId: state.sessionId,
|
|
327
|
+
terminal: state.terminal,
|
|
328
|
+
contextLength: state.contextLength,
|
|
329
|
+
compactCount: state.compactCount,
|
|
330
|
+
cacheReadTokens: state.cacheReadTokens,
|
|
331
|
+
cacheCreationTokens: state.cacheCreationTokens,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Whether any claude run is currently in progress (any workspace). */
|
|
335
|
+
get isBusy() {
|
|
336
|
+
return this.activeRuns.size > 0;
|
|
337
|
+
}
|
|
338
|
+
/** Get all active runs across all workspaces. */
|
|
339
|
+
getAllActiveRuns() {
|
|
340
|
+
const result = new Map();
|
|
341
|
+
for (const [cwd, active] of this.activeRuns) {
|
|
342
|
+
result.set(cwd, {
|
|
343
|
+
runId: active.runId,
|
|
344
|
+
userId: active.userId,
|
|
345
|
+
chatId: active.chatId,
|
|
346
|
+
cwd: active.cwd,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
/** Get all active runs with their state for /active command (memory-based). */
|
|
352
|
+
getActiveRuns() {
|
|
353
|
+
const result = [];
|
|
354
|
+
for (const [_cwd, active] of this.activeRuns) {
|
|
355
|
+
const state = active.session.currentState;
|
|
356
|
+
// Only include non-terminal runs
|
|
357
|
+
if (state.terminal === 'running' || state.terminal === 'finalizing') {
|
|
358
|
+
result.push({
|
|
359
|
+
runId: active.runId,
|
|
360
|
+
sessionId: state.sessionId ?? '',
|
|
361
|
+
cwd: active.cwd,
|
|
362
|
+
userId: active.userId,
|
|
363
|
+
chatId: active.chatId,
|
|
364
|
+
terminal: state.terminal,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
/** Get all active bash runs with their state for /active command. */
|
|
371
|
+
getActiveBashRuns() {
|
|
372
|
+
const result = [];
|
|
373
|
+
for (const [runId, bashRun] of this.activeBashRuns) {
|
|
374
|
+
const state = bashRun.cardSession.currentState;
|
|
375
|
+
// Only include non-terminal runs (bash has no finalizing state)
|
|
376
|
+
if (state.terminal === 'running') {
|
|
377
|
+
result.push({
|
|
378
|
+
runId,
|
|
379
|
+
cwd: bashRun.cwd,
|
|
380
|
+
userId: bashRun.userId,
|
|
381
|
+
chatId: bashRun.chatId,
|
|
382
|
+
terminal: state.terminal,
|
|
383
|
+
command: bashRun.command,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
async interruptCurrentRun(input) {
|
|
390
|
+
// Find the active claude run matching this user/chat
|
|
391
|
+
for (const [cwd, active] of this.activeRuns) {
|
|
392
|
+
if (input.workspace !== undefined && cwd !== input.workspace)
|
|
393
|
+
continue;
|
|
394
|
+
if (active.userId === input.userId && active.chatId === input.chatId) {
|
|
395
|
+
if (input.runId && active.runId !== input.runId)
|
|
396
|
+
continue;
|
|
397
|
+
getLogger().info(`[bridge] interrupt hit runId=${active.runId} cwd=${cwd} userId=${input.userId}`);
|
|
398
|
+
// Stop the EXACT runner instance that is running (carried by activeRun),
|
|
399
|
+
// not whatever getRunner(cwd) would return now. clearRunners() may have
|
|
400
|
+
// emptied the cache mid-run, in which case getRunner creates a fresh empty
|
|
401
|
+
// runner whose stop() is a no-op and the real subprocess is never killed
|
|
402
|
+
// (regression 2026-07-18: queue chain blocked forever).
|
|
403
|
+
const runner = active.runner;
|
|
404
|
+
// Bind the queue reset to THIS task's execution slot: the stop window
|
|
405
|
+
// below may outlive the task's settle, letting the serial chain advance
|
|
406
|
+
// to a successor that begins executing before we resume. An
|
|
407
|
+
// unconditional reset would then zero the successor's count and mark
|
|
408
|
+
// its slot interrupted, hiding it from queue cards (A22).
|
|
409
|
+
const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
|
|
410
|
+
await Promise.allSettled([
|
|
411
|
+
active.session.finish('interrupted'),
|
|
412
|
+
runner.stop({ immediate: true }),
|
|
413
|
+
]);
|
|
414
|
+
// The chain may have advanced while the stop was in flight: a new run
|
|
415
|
+
// (from a task that began during the stop window) may now occupy this
|
|
416
|
+
// workspace. Only delete the entry we actually stopped.
|
|
417
|
+
if (this.activeRuns.get(cwd) === active) {
|
|
418
|
+
this.activeRuns.delete(cwd);
|
|
419
|
+
}
|
|
420
|
+
// Fix 4: Clean up this specific (cwd, agentKind) runner slot
|
|
421
|
+
// (P1-1: eviction must also unregister the runner from the exit dispatcher)
|
|
422
|
+
// P2-3: evict by the run's captured agentKind, not the live
|
|
423
|
+
// defaultAgent (may have changed mid-run via /config).
|
|
424
|
+
this.evictRunnerSlot(cwd, active.agentKind);
|
|
425
|
+
// Reset queue executing count: the task was interrupted externally, so
|
|
426
|
+
// its promise-chain settle (.then/.catch) may lag behind the process
|
|
427
|
+
// kill. Without this reset, subsequent messages would incorrectly show
|
|
428
|
+
// as "排队中". resetExecutingCount also grants a skip-credit so the
|
|
429
|
+
// stale settle does not double-decrement the count. Centralized here so
|
|
430
|
+
// ALL interrupt paths (/stop, /t, card stop, queue.immediate) benefit.
|
|
431
|
+
// When stoppedSlot is undefined (no active run matched), skip the reset —
|
|
432
|
+
// there is no queue slot to reconcile. All production interrupt paths
|
|
433
|
+
// (/stop, /t, card stop, queue.immediate) provide a defined stoppedSlot.
|
|
434
|
+
if (stoppedSlot !== undefined) {
|
|
435
|
+
this.queueManager.resetExecutingCount(cwd, stoppedSlot);
|
|
436
|
+
}
|
|
437
|
+
getLogger().info(`[bridge] interrupt stop done runId=${active.runId}`);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Then check active bash runs (! commands). These bypass the serial queue
|
|
442
|
+
// and are tracked separately; stop the per-run BashRunner directly.
|
|
443
|
+
for (const [runId, b] of this.activeBashRuns) {
|
|
444
|
+
if (input.workspace !== undefined && b.cwd !== input.workspace)
|
|
445
|
+
continue;
|
|
446
|
+
if (b.userId === input.userId && b.chatId === input.chatId) {
|
|
447
|
+
if (input.runId && runId !== input.runId)
|
|
448
|
+
continue;
|
|
449
|
+
getLogger().info(`[bridge] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
|
|
450
|
+
await b.bashRunner.stop({ immediate: true });
|
|
451
|
+
// Update card to show interrupted state
|
|
452
|
+
await b.cardSession.finish('interrupted', { exitCode: -1 });
|
|
453
|
+
this.activeBashRuns.delete(runId);
|
|
454
|
+
getLogger().info(`[bridge] interrupt bash stop done runId=${runId}`);
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
getLogger().debug(`[bridge] interrupt miss userId=${input.userId}`);
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
/** Send a result message to the user (§7). Returns true on success, false on failure. */
|
|
462
|
+
async sendResult(result, ctx) {
|
|
463
|
+
try {
|
|
464
|
+
if (result.card) {
|
|
465
|
+
// 静态卡片体积保护:确保卡片不超过飞书大小限制
|
|
466
|
+
const { card: safeCard, wasTruncated, reason, bytesBefore, bytesAfter, } = enforceCardBudget(result.card);
|
|
467
|
+
if (wasTruncated) {
|
|
468
|
+
getLogger().info(`[bridge] card size exceeded limit, applied budget enforcement: reason=${reason} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter}`);
|
|
469
|
+
}
|
|
470
|
+
getLogger().debug(`[bridge] sendResult kind=card chatId=${ctx.chatId}`);
|
|
471
|
+
await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
else if (result.markdown) {
|
|
475
|
+
getLogger().debug(`[bridge] sendResult kind=markdown chatId=${ctx.chatId}`);
|
|
476
|
+
await this.connector.sendWithRetry(ctx.chatId, { markdown: result.markdown }, { replyTo: ctx.messageId });
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
else if (result.text) {
|
|
480
|
+
getLogger().debug(`[bridge] sendResult kind=text chatId=${ctx.chatId}`);
|
|
481
|
+
await this.connector.sendWithRetry(ctx.chatId, { text: result.text }, { replyTo: ctx.messageId });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
getLogger().error('[bridge] failed to send result:', err);
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Update an existing card in place (原地更新) instead of sending a new one.
|
|
493
|
+
*
|
|
494
|
+
* Used by /config cardAction handlers (config.toggle/set/input/save) so the
|
|
495
|
+
* user sees the card refresh on the same message bubble rather than getting
|
|
496
|
+
* a new card per click — same-message-bubble update pattern.
|
|
497
|
+
*
|
|
498
|
+
* Falls back to `sendResult` (which sends a new card as reply) when:
|
|
499
|
+
* - `messageId` is missing (caller didn't pass ctx.messageId)
|
|
500
|
+
* - `connector.updateCard` throws (card was deleted, network error, etc.)
|
|
501
|
+
*
|
|
502
|
+
* Returns true if the in-place update succeeded, false if it fell back.
|
|
503
|
+
*/
|
|
504
|
+
async updateCardInPlace(card, ctx) {
|
|
505
|
+
if (!ctx.messageId) {
|
|
506
|
+
getLogger().warn('[bridge] updateCardInPlace missing messageId, falling back to sendResult');
|
|
507
|
+
await this.sendResult({ card }, ctx);
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
getLogger().debug(`[bridge] updateCardInPlace messageId=${ctx.messageId}`);
|
|
512
|
+
await this.connector.updateCard(ctx.messageId, card);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
getLogger().warn(`[bridge] updateCardInPlace failed, falling back to sendResult: ${err.message}`);
|
|
517
|
+
await this.sendResult({ card }, ctx);
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/** Send a file to the user via Feishu. */
|
|
522
|
+
async sendFile(filePath, ctx) {
|
|
523
|
+
try {
|
|
524
|
+
getLogger().debug(`[bridge] sendFile path=${filePath} chatId=${ctx.chatId}`);
|
|
525
|
+
await this.connector.sendFile(ctx.chatId, filePath);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
getLogger().error('[bridge] failed to send file:', err);
|
|
529
|
+
await this.sendResult({ text: `发送文件失败: ${err.message}` }, ctx);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** Reconnect the underlying Feishu channel. */
|
|
533
|
+
async reconnect() {
|
|
534
|
+
await this.connector.reconnect();
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Resolve the working directory for a user.
|
|
538
|
+
* First checks sessionStore (set by /cd), then falls back to first workspace.
|
|
539
|
+
* Returns undefined if no cwd is available.
|
|
540
|
+
*/
|
|
541
|
+
resolveCwd(userId) {
|
|
542
|
+
const entry = this.sessionStore.get(userId);
|
|
543
|
+
let cwd = entry?.cwd;
|
|
544
|
+
if (!cwd && this.workspaceStore) {
|
|
545
|
+
const workspaces = this.workspaceStore.list();
|
|
546
|
+
if (workspaces.length > 0) {
|
|
547
|
+
cwd = workspaces[0][1];
|
|
548
|
+
getLogger().info(`[bridge] resolveCwd fallback cwd=${cwd} (first saved workspace)`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return cwd;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Forward a non-command message to claude.
|
|
555
|
+
*
|
|
556
|
+
* Watchdog (§9.12): if no AgentEvent arrives within IDLE_TIMEOUT_MS, the
|
|
557
|
+
* run is considered hung and `runner.stop()` is called to unblock the
|
|
558
|
+
* work queue. Session is synced from `system.init`; every renderable event
|
|
559
|
+
* updates the same run card, and terminal events finalize that card.
|
|
560
|
+
*/
|
|
561
|
+
async forwardToClaude(message, ctx, opts) {
|
|
562
|
+
// D5: agentKind 一律来自绑定。无 binding 时保持现状(live defaultAgent)。
|
|
563
|
+
const agentKind = opts?.binding?.agent ?? this.config.defaultAgent;
|
|
564
|
+
// P1-14: lane 与执行 cwd 同源。消息入队时(index.ts 闭包)捕获的 workspace
|
|
565
|
+
// 作为 cwdOverride 显式传入;执行时若 sessionStore cwd 已被 /cd 等命令改写,
|
|
566
|
+
// 以 lane 为准 —— 否则旧 lane 的排队消息会被 busy-drop 静默丢失。空串视为
|
|
567
|
+
// 未提供(与入队前无 cwd 的兜底语义一致)。
|
|
568
|
+
const laneCwd = opts?.cwdOverride;
|
|
569
|
+
const cwd = laneCwd && laneCwd.length > 0 ? laneCwd : this.resolveCwd(ctx.userId);
|
|
570
|
+
if (!cwd) {
|
|
571
|
+
await this.sendResult({ text: '请先使用 /cd <path> 设置工作目录' }, ctx);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const entry = this.sessionStore.get(ctx.userId);
|
|
575
|
+
getLogger().debug(`[bridge] forward entry userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} ` +
|
|
576
|
+
`agent=${agentKind} sessionId=${opts?.binding?.sessionId ?? entry?.sessions?.get(agentKind) ?? '(none)'} message=${message.slice(0, 100)}`);
|
|
577
|
+
// Check if current workspace already has a run in progress
|
|
578
|
+
if (this.activeRuns.has(cwd)) {
|
|
579
|
+
getLogger().warn(`[bridge] workspace busy, dropping message userId=${ctx.userId} cwd=${cwd}`);
|
|
580
|
+
await this.sendResult({ text: '此 workspace 正在处理中,请 /stop 后重试' }, ctx);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Note: Multiple workspaces can have runs in parallel. If runner is busy,
|
|
584
|
+
// runner.run() will throw and we'll catch it below.
|
|
585
|
+
// D2/D5: sessionId 钉死语义——binding 有 sessionId 时用它(入队时刻快照),
|
|
586
|
+
// 否则跟随 live store(无 binding 或入队时无 session 的正常路径)。
|
|
587
|
+
const sessionId = opts?.binding?.sessionId ?? this.sessionStore.getSessionId(ctx.userId, agentKind);
|
|
588
|
+
// 会话代际快照(2026-08-09):run 在途时 /new、/cd、/resume 会 bump epoch,
|
|
589
|
+
// 该 run 后续 system.init 的 sessionId 写回即判 stale 跳过。在执行起点
|
|
590
|
+
// (而非入队点)捕获,保持 binding「钉死 resume」既有语义。
|
|
591
|
+
const sessionEpochAtStart = this.sessionStore.getSessionEpoch(ctx.userId, agentKind);
|
|
592
|
+
// Resolve the runner BEFORE creating the run session so the exact instance
|
|
593
|
+
// that will run is captured into activeRun. interruptCurrentRun stops THAT
|
|
594
|
+
// instance directly -- never a fresh runner re-resolved from a cache that
|
|
595
|
+
// clearRunners() may have emptied mid-run (regression 2026-07-18).
|
|
596
|
+
const runner = this.getRunner(cwd, agentKind);
|
|
597
|
+
// Step 1: Create run session (runId, cardSession, activeRun tracking)
|
|
598
|
+
const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
|
|
599
|
+
// Step 2: Consume the agent stream (runner.run loop, event processing, state updates)
|
|
600
|
+
await this.runAgentStreamToEnd(runner, cardSession, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind);
|
|
601
|
+
// Step 3: Finalize the run (error handling, fallback card, completion notification, cleanup)
|
|
602
|
+
await this.finalizeRun(cardSession, activeRun, ctx, cwd);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Step 1 of forwardToClaude: Create run session components.
|
|
606
|
+
* Returns runId, cardSession, and activeRun object for tracking.
|
|
607
|
+
*/
|
|
608
|
+
createRunSession(ctx, cwd, runner, agentKind = this.config.defaultAgent) {
|
|
609
|
+
const runId = randomUUID();
|
|
610
|
+
getLogger().debug(`[bridge] creating runCard runId=${runId}`);
|
|
611
|
+
// D5: agentKind 来自入队绑定(forwardToClaude 解析),不再读 live config。
|
|
612
|
+
// 缓存键 (cwd, agentKind) 和 eviction 都用这个值(review P2-3)。
|
|
613
|
+
const cardSession = new RunCardSession({
|
|
614
|
+
connector: this.connector,
|
|
615
|
+
chatId: ctx.chatId,
|
|
616
|
+
replyTo: ctx.messageId,
|
|
617
|
+
runId,
|
|
618
|
+
renderOptions: {
|
|
619
|
+
showThinking: this.config.output.showThinking,
|
|
620
|
+
showToolUse: this.config.output.showToolUse,
|
|
621
|
+
showToolResult: this.config.output.showToolResult,
|
|
622
|
+
// Run card header shows the current agent name (e.g. "Claude · 思考中")
|
|
623
|
+
agentKind,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
const activeRun = {
|
|
627
|
+
runId,
|
|
628
|
+
userId: ctx.userId,
|
|
629
|
+
chatId: ctx.chatId,
|
|
630
|
+
session: cardSession,
|
|
631
|
+
cwd,
|
|
632
|
+
runner,
|
|
633
|
+
agentKind,
|
|
634
|
+
};
|
|
635
|
+
this.activeRuns.set(cwd, activeRun);
|
|
636
|
+
getLogger().info(`[bridge] activeRuns.set cwd=${cwd} runId=${runId}`);
|
|
637
|
+
return { runId, cardSession, activeRun };
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Step 2 of forwardToClaude: Run the agent and consume its event stream.
|
|
641
|
+
* Handles idle watchdog, event processing, session sync, and state transitions.
|
|
642
|
+
*/
|
|
643
|
+
async runAgentStreamToEnd(runner, cardSession, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind = this.config.defaultAgent) {
|
|
644
|
+
// P2-1: idle watchdog 用单 interval + lastEventTs 截止时间判定,而非每事件
|
|
645
|
+
// clearTimeout + setTimeout 重建 timer。高频事件流下 timer 重建次数与事件数
|
|
646
|
+
// 成正比(N 事件 = N 次 setTimeout + N 个闭包/秒);interval 方案只建一个
|
|
647
|
+
// interval,事件到来时仅刷新时间戳(无 timer 操作)。语义不变:事件流停滞
|
|
648
|
+
// 超过 idleTimeoutMs 仍触发 runner.stop()(§9.12)。
|
|
649
|
+
let lastEventTs = Date.now();
|
|
650
|
+
let idleInterval = null;
|
|
651
|
+
let sawResult = false;
|
|
652
|
+
// 记录 result 是否为 error: error 时跳过 jsonl usage 读取 (见 stream end 处说明)
|
|
653
|
+
let resultWasError = false;
|
|
654
|
+
// Final usage read from jsonl after the run completes. live stream-json does
|
|
655
|
+
// not emit compact_boundary, so the live contextLength is unreliable.
|
|
656
|
+
let finalContextLength;
|
|
657
|
+
let finalCompactCount;
|
|
658
|
+
let finalCacheReadTokens;
|
|
659
|
+
let finalCacheCreationTokens;
|
|
660
|
+
let finalTotalTokens;
|
|
661
|
+
// Real input/output tokens captured from the live result event (codex/
|
|
662
|
+
// opencode carry them in ResultEvent.usage). Threaded to the card so the
|
|
663
|
+
// done card shows real values instead of the 10% estimate.
|
|
664
|
+
let liveInputTokens;
|
|
665
|
+
let liveOutputTokens;
|
|
666
|
+
// Final input/output for the done card: live value wins (codex/opencode
|
|
667
|
+
// carry per-run usage in the result event); jsonl is the fallback for
|
|
668
|
+
// agents whose live stream has no usage (kimi).
|
|
669
|
+
let finalInputTokens;
|
|
670
|
+
let finalOutputTokens;
|
|
671
|
+
// Session-cumulative input/output (all runs) read from jsonl; threaded to
|
|
672
|
+
// the done card's "累计" display. Always from jsonl (live has no cumulative).
|
|
673
|
+
let finalCumulativeTotalTokens;
|
|
674
|
+
let finalCumulativeInputTokens;
|
|
675
|
+
let finalCumulativeOutputTokens;
|
|
676
|
+
let finalCumulativeCacheReadTokens;
|
|
677
|
+
let finalCumulativeCacheCreationTokens;
|
|
678
|
+
try {
|
|
679
|
+
try {
|
|
680
|
+
getLogger().debug(`[bridge] cardSession.start() begin runId=${runId}`);
|
|
681
|
+
await cardSession.start();
|
|
682
|
+
getLogger().info(`[bridge] cardSession.start() ok runId=${runId}`);
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
getLogger().warn('[bridge] card stream unavailable, will use static fallback:', err);
|
|
686
|
+
// Card stream failed - continue with static fallback below, but ensure
|
|
687
|
+
// we don't silently fail if the fallback also fails (covered in catch below).
|
|
688
|
+
}
|
|
689
|
+
if (cardSession.currentState.terminal !== 'running') {
|
|
690
|
+
// Card session did not enter running state - still try to send static fallback
|
|
691
|
+
getLogger().info(`[bridge] cardSession not running, using static fallback`);
|
|
692
|
+
}
|
|
693
|
+
// P2-1: 单 interval 看门狗。事件到来时只刷新 lastEventTs(无 timer 操作);
|
|
694
|
+
// interval 周期检查 `now - lastEventTs > idleTimeoutMs`,超时才触发 stop。
|
|
695
|
+
// 这样高频事件流下 timer 重建次数与事件数解耦(只建一个 interval),而
|
|
696
|
+
// "15min 无事件即 stop" 的语义不变(§9.12)。周期取 idleTimeoutMs/2,使停滞
|
|
697
|
+
// 后最多在 1.5× idleTimeoutMs 内触发(半个窗口延迟,watchdog 语义可接受)。
|
|
698
|
+
const fireIdleTimeout = () => {
|
|
699
|
+
if (idleInterval)
|
|
700
|
+
clearInterval(idleInterval);
|
|
701
|
+
idleInterval = null;
|
|
702
|
+
getLogger().warn(`[bridge] claude idle timeout, stopping process runId=${runId}`);
|
|
703
|
+
void Promise.allSettled([
|
|
704
|
+
cardSession.finish('idle_timeout', {
|
|
705
|
+
idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
|
|
706
|
+
}),
|
|
707
|
+
runner.stop(),
|
|
708
|
+
]);
|
|
709
|
+
};
|
|
710
|
+
const resetIdle = () => {
|
|
711
|
+
lastEventTs = Date.now();
|
|
712
|
+
};
|
|
713
|
+
if (this.idleTimeoutMs > 0) {
|
|
714
|
+
const tickMs = Math.max(1, Math.floor(this.idleTimeoutMs / 2));
|
|
715
|
+
idleInterval = setInterval(() => {
|
|
716
|
+
if (Date.now() - lastEventTs > this.idleTimeoutMs) {
|
|
717
|
+
fireIdleTimeout();
|
|
718
|
+
}
|
|
719
|
+
}, tickMs);
|
|
720
|
+
}
|
|
721
|
+
resetIdle();
|
|
722
|
+
let contextLength;
|
|
723
|
+
getLogger().info(`[bridge] runner.run() begin runId=${runId} message=${message.slice(0, 100)}`);
|
|
724
|
+
// Extract agent-specific options from config
|
|
725
|
+
const agentOpts = this.getAgentRunOptions(agentKind);
|
|
726
|
+
const runOpts = { cwd, sessionId, ...agentOpts };
|
|
727
|
+
for await (const event of runner.run(message, runOpts)) {
|
|
728
|
+
// 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
|
|
729
|
+
// 这样在 result 事件后,后台任务输出仍能被正确处理
|
|
730
|
+
const terminal = cardSession.currentState.terminal;
|
|
731
|
+
if (terminal === 'done' ||
|
|
732
|
+
terminal === 'error' ||
|
|
733
|
+
terminal === 'interrupted' ||
|
|
734
|
+
terminal === 'idle_timeout') {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
738
|
+
// 代际守卫:run 在途时 /new(或 new-session 卡片、/cd、/resume、
|
|
739
|
+
// /config 切换)移动了 session 指针,此 init 的写回是 stale 的——
|
|
740
|
+
// 跳过,否则 /new 的清空会被在途 run 静默撤销(2026-08-09 事故:
|
|
741
|
+
// task-notification 注入触发 Claude 重发 init,e8defc46 复活)。
|
|
742
|
+
const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
|
|
743
|
+
if (pointerMoved) {
|
|
744
|
+
getLogger().info(`[bridge] system.init write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.session_id}`);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
// L5: Use session's real directory from event.cwd (not runner's cwd parameter)
|
|
748
|
+
// L3: guard empty string too -- `??` only catches null/undefined, but a
|
|
749
|
+
// translator (or older build) may emit cwd="", which must NOT overwrite
|
|
750
|
+
// the good runner cwd.
|
|
751
|
+
const hasCwd = !!this.sessionStore.getCwd(ctx.userId);
|
|
752
|
+
const realCwd = event.cwd && event.cwd.length > 0 ? event.cwd : cwd;
|
|
753
|
+
if (hasCwd && event.cwd && event.cwd !== cwd) {
|
|
754
|
+
// Session has its own cwd (e.g. EnterWorktree relocate). Keep the
|
|
755
|
+
// workspace cwd unchanged; record the session's actual cwd in sessionCwds.
|
|
756
|
+
getLogger().info(`[bridge] system.init: workspace cwd=${cwd}, session cwd=${event.cwd}`);
|
|
757
|
+
this.sessionStore.setSessionIdAndSessionCwd(ctx.userId, agentKind, event.session_id, realCwd);
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
// First use (no workspace cwd yet) or event.cwd matches runner cwd:
|
|
761
|
+
// set sessionId + cwd as before; on first use also bootstrap sessionCwds.
|
|
762
|
+
const bootstrapSessionCwd = !hasCwd && event.cwd && event.cwd.length > 0 ? event.cwd : undefined;
|
|
763
|
+
this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.session_id, realCwd, bootstrapSessionCwd);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
getLogger().info(`[bridge] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
|
|
767
|
+
}
|
|
768
|
+
// Track context length from compaction events
|
|
769
|
+
if (event.type === 'system' &&
|
|
770
|
+
event.subtype === 'compact_boundary' &&
|
|
771
|
+
event.compactMetadata) {
|
|
772
|
+
contextLength = event.compactMetadata.postTokens;
|
|
773
|
+
}
|
|
774
|
+
if (event.type === 'result') {
|
|
775
|
+
sawResult = true;
|
|
776
|
+
resultWasError = event.subtype === 'error';
|
|
777
|
+
getLogger().info(`[bridge] result event received runId=${runId}`);
|
|
778
|
+
// result 后进入 finalizing 非终态(暂存 subtype/errorMsg),由 for-await
|
|
779
|
+
// 自然结束后在 finally 转 done/error。提取 usage 信息。
|
|
780
|
+
if (event.usage) {
|
|
781
|
+
const u = normalizeResultUsage(event.usage);
|
|
782
|
+
// input_tokens is non-cached (ccusage-aligned), so the context
|
|
783
|
+
// length fallback must include cached tokens. Prefer the agent's
|
|
784
|
+
// declared total; otherwise reconstruct from the parts.
|
|
785
|
+
if (!contextLength) {
|
|
786
|
+
contextLength = u.contextLength;
|
|
787
|
+
}
|
|
788
|
+
// Capture real input/output for the done card (avoiding the 10%
|
|
789
|
+
// estimate in formatUsageStats). live 值优先:有 live input/output 时
|
|
790
|
+
// 所有 flow 字段统一用 live scope(本 run),避免同一张卡片上
|
|
791
|
+
// Input/Output 是本 run、Cache/Total 是 session 累计的混 scope 问题。
|
|
792
|
+
liveInputTokens = u.inputTokens;
|
|
793
|
+
liveOutputTokens = u.outputTokens;
|
|
794
|
+
if (u.cacheReadTokens !== undefined) {
|
|
795
|
+
finalCacheReadTokens = u.cacheReadTokens;
|
|
796
|
+
}
|
|
797
|
+
if (u.cacheCreationTokens !== undefined) {
|
|
798
|
+
finalCacheCreationTokens = u.cacheCreationTokens;
|
|
799
|
+
}
|
|
800
|
+
if (u.totalTokens !== undefined) {
|
|
801
|
+
finalTotalTokens = u.totalTokens;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// §9.12 红线(§P1-2 方案 A):每个事件(含 result)无条件刷新
|
|
806
|
+
// lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
|
|
807
|
+
// 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
|
|
808
|
+
// idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
|
|
809
|
+
resetIdle();
|
|
810
|
+
await cardSession.push(event);
|
|
811
|
+
}
|
|
812
|
+
getLogger().info(`[bridge] runner stream end runId=${runId} sawResult=${sawResult}`);
|
|
813
|
+
// §9.12 红线(§P1-2 方案 A 第 2 点):for-await 自然结束 = CLI 已退出,
|
|
814
|
+
// 看门狗使命结束,立即摘除 interval(不再等 finally),堵掉「stream 已结束、
|
|
815
|
+
// 收尾处理跨过死线被误标 idle_timeout」的同源小窗口(P2-4 随本修复一并关闭)。
|
|
816
|
+
if (idleInterval) {
|
|
817
|
+
clearInterval(idleInterval);
|
|
818
|
+
idleInterval = null;
|
|
819
|
+
}
|
|
820
|
+
// stream 结束 = CLI 退出 = jsonl 已落盘。读 jsonl 拿准确的 contextLength + compactCount:
|
|
821
|
+
// live stream-json 不发 compact_boundary,live contextLength 会被 result 的 input+output 兜底失真。
|
|
822
|
+
// 但 result=error 时跳过: 进程可能立即退出未写新 usage (如 kimi 0.26+ 拒绝 -p+--auto
|
|
823
|
+
// 立即 exit=1), 此时 jsonl 里是上一次成功 turn 的历史 usage, 会被误当作本次 usage 显示,
|
|
824
|
+
// 误导用户以为本次出错的请求也消耗了 token。error 时只用 live 捕获的 usage (若有)。
|
|
825
|
+
const finalSessionId = this.sessionStore.getSessionId(ctx.userId, agentKind);
|
|
826
|
+
const finalUsage = resultWasError
|
|
827
|
+
? undefined
|
|
828
|
+
: this.resolveFinalUsage(finalSessionId, cwd, agentKind);
|
|
829
|
+
finalContextLength = finalUsage?.contextLength ?? contextLength;
|
|
830
|
+
finalCompactCount = finalUsage?.compactCount;
|
|
831
|
+
finalCumulativeTotalTokens = finalUsage?.cumulativeTotalTokens;
|
|
832
|
+
finalCumulativeInputTokens = finalUsage?.cumulativeInputTokens;
|
|
833
|
+
finalCumulativeOutputTokens = finalUsage?.cumulativeOutputTokens;
|
|
834
|
+
finalCumulativeCacheReadTokens = finalUsage?.cumulativeCacheReadTokens;
|
|
835
|
+
finalCumulativeCacheCreationTokens = finalUsage?.cumulativeCacheCreationTokens;
|
|
836
|
+
// 统一 flow 字段 scope:live 优先(非 codex),jsonl 兜底;codex 例外。
|
|
837
|
+
// 当 live result 事件带了 input/output(即 liveInputTokens/liveOutputTokens
|
|
838
|
+
// 非 undefined),所有 flow 字段统一用 live scope(本 run),不用 jsonl
|
|
839
|
+
// 的 session 累计值覆盖,避免 cache%/Total 分子分母不同源。
|
|
840
|
+
// 无 live usage 时(如 kimi),jsonl 值兜底。
|
|
841
|
+
// codex 例外(2026-08-01):codex exec 的 turn.completed.usage 是会话累计
|
|
842
|
+
// total_token_usage(resume 长会话下虚高数百倍),不是本 run 增量——live
|
|
843
|
+
// 值必须被忽略,flow 字段一律取 jsonl 主线程文件的 per-turn 值;
|
|
844
|
+
// jsonl 缺失(老版本无 token_count)时才回退 live。
|
|
845
|
+
// contextLength/compactCount 保持 jsonl 优先(它们是水位/历史计数)。
|
|
846
|
+
const codexJsonlFirst = agentKind === 'codex';
|
|
847
|
+
if (!codexJsonlFirst && liveInputTokens !== undefined && liveOutputTokens !== undefined) {
|
|
848
|
+
// 非 codex 且 live 有 input/output → 所有 flow 字段用 live scope
|
|
849
|
+
finalInputTokens = liveInputTokens;
|
|
850
|
+
finalOutputTokens = liveOutputTokens;
|
|
851
|
+
// cacheReadTokens/cacheCreationTokens/totalTokens
|
|
852
|
+
// 已在上面 result 事件处理中赋了 live 值;若 live 没有则为 undefined,
|
|
853
|
+
// 由 formatUsageStats 的 max/兜底公式处理。
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
// codex(jsonl 优先)或无 live usage → jsonl 兜底所有 flow 字段
|
|
857
|
+
finalInputTokens = finalUsage?.inputTokens ?? liveInputTokens;
|
|
858
|
+
finalOutputTokens = finalUsage?.outputTokens ?? liveOutputTokens;
|
|
859
|
+
finalCacheReadTokens = finalUsage?.cacheReadTokens ?? finalCacheReadTokens;
|
|
860
|
+
finalCacheCreationTokens = finalUsage?.cacheCreationTokens ?? finalCacheCreationTokens;
|
|
861
|
+
finalTotalTokens = finalUsage?.totalTokens ?? finalTotalTokens;
|
|
862
|
+
}
|
|
863
|
+
getLogger().info(`[bridge] final usage runId=${runId} contextLength=${finalContextLength} compactCount=${finalCompactCount ?? 'undefined'} cacheRead=${finalCacheReadTokens ?? 'undefined'} cacheCreate=${finalCacheCreationTokens ?? 'undefined'}`);
|
|
864
|
+
// for-await 自然结束 = stream 关闭 = CLI 进程退出。
|
|
865
|
+
// 进程退出(for-await end)= 唯一终态触发源。
|
|
866
|
+
// 1) finalizing(result 已收到)-> 转 done/error + usage meta
|
|
867
|
+
// 2) running(未收到 result)-> 错误路径
|
|
868
|
+
// 3) 已终态 + sawResult -> 补充 usage meta(不改 terminal)
|
|
869
|
+
const finalState = cardSession.currentState;
|
|
870
|
+
if (finalState.terminal === 'finalizing') {
|
|
871
|
+
const isError = finalState.resultSubtype === 'error' || !!finalState.errorMsg;
|
|
872
|
+
await cardSession.finish(isError ? 'error' : 'done', {
|
|
873
|
+
resultSubtype: finalState.resultSubtype,
|
|
874
|
+
errorMsg: finalState.errorMsg,
|
|
875
|
+
contextLength: finalContextLength,
|
|
876
|
+
compactCount: finalCompactCount,
|
|
877
|
+
cacheReadTokens: finalCacheReadTokens,
|
|
878
|
+
cacheCreationTokens: finalCacheCreationTokens,
|
|
879
|
+
totalTokens: finalTotalTokens,
|
|
880
|
+
inputTokens: finalInputTokens,
|
|
881
|
+
outputTokens: finalOutputTokens,
|
|
882
|
+
cumulativeTotalTokens: finalCumulativeTotalTokens,
|
|
883
|
+
cumulativeInputTokens: finalCumulativeInputTokens,
|
|
884
|
+
cumulativeOutputTokens: finalCumulativeOutputTokens,
|
|
885
|
+
cumulativeCacheReadTokens: finalCumulativeCacheReadTokens,
|
|
886
|
+
cumulativeCacheCreationTokens: finalCumulativeCacheCreationTokens,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
else if (finalState.terminal === 'running') {
|
|
890
|
+
await cardSession.finish('error', {
|
|
891
|
+
contextLength: finalContextLength,
|
|
892
|
+
compactCount: finalCompactCount,
|
|
893
|
+
cacheReadTokens: finalCacheReadTokens,
|
|
894
|
+
cacheCreationTokens: finalCacheCreationTokens,
|
|
895
|
+
totalTokens: finalTotalTokens,
|
|
896
|
+
inputTokens: finalInputTokens,
|
|
897
|
+
outputTokens: finalOutputTokens,
|
|
898
|
+
cumulativeTotalTokens: finalCumulativeTotalTokens,
|
|
899
|
+
cumulativeInputTokens: finalCumulativeInputTokens,
|
|
900
|
+
cumulativeOutputTokens: finalCumulativeOutputTokens,
|
|
901
|
+
cumulativeCacheReadTokens: finalCumulativeCacheReadTokens,
|
|
902
|
+
cumulativeCacheCreationTokens: finalCumulativeCacheCreationTokens,
|
|
903
|
+
errorMsg: agentDisplayName(agentKind) + ' 输出流已结束,但未收到 result 事件',
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
else if (sawResult) {
|
|
907
|
+
// 已终态(interrupted/idle_timeout)+ result 已收到:首终态优先,
|
|
908
|
+
// 仅补充 usage meta(finalizing 期间被 /stop 或 idle 超时也展示 token 统计)
|
|
909
|
+
await cardSession.finish(finalState.terminal, {
|
|
910
|
+
contextLength: finalContextLength,
|
|
911
|
+
compactCount: finalCompactCount,
|
|
912
|
+
cacheReadTokens: finalCacheReadTokens,
|
|
913
|
+
cacheCreationTokens: finalCacheCreationTokens,
|
|
914
|
+
totalTokens: finalTotalTokens,
|
|
915
|
+
inputTokens: finalInputTokens,
|
|
916
|
+
outputTokens: finalOutputTokens,
|
|
917
|
+
cumulativeTotalTokens: finalCumulativeTotalTokens,
|
|
918
|
+
cumulativeInputTokens: finalCumulativeInputTokens,
|
|
919
|
+
cumulativeOutputTokens: finalCumulativeOutputTokens,
|
|
920
|
+
cumulativeCacheReadTokens: finalCumulativeCacheReadTokens,
|
|
921
|
+
cumulativeCacheCreationTokens: finalCumulativeCacheCreationTokens,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
// else: 已终态且未收到 result(如 spawn 失败后 interrupted)-> 无 usage 可补,跳过
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
getLogger().error(`[bridge] claude run error runId=${runId}:`, err);
|
|
928
|
+
// P1-11 双保险:run() 的 finally 已负责杀子进程,但 catch 路径再补一次
|
|
929
|
+
// runner.stop()(与 bash 路径对齐),覆盖任何 finally 之外的残留窗口。
|
|
930
|
+
try {
|
|
931
|
+
await runner.stop();
|
|
932
|
+
}
|
|
933
|
+
catch (stopErr) {
|
|
934
|
+
getLogger().warn(`[bridge] runner stop failed:`, stopErr);
|
|
935
|
+
}
|
|
936
|
+
// 终态守卫:running 或 finalizing(非终态)-> 转 error;
|
|
937
|
+
// 已终态(interrupted/idle_timeout/done/error)-> 保留首终态,仅补充 usage。
|
|
938
|
+
const catchTerminal = cardSession.currentState.terminal;
|
|
939
|
+
if (catchTerminal === 'running' || catchTerminal === 'finalizing') {
|
|
940
|
+
await cardSession.finish('error', {
|
|
941
|
+
contextLength: finalContextLength,
|
|
942
|
+
compactCount: finalCompactCount,
|
|
943
|
+
cacheReadTokens: finalCacheReadTokens,
|
|
944
|
+
cacheCreationTokens: finalCacheCreationTokens,
|
|
945
|
+
totalTokens: finalTotalTokens,
|
|
946
|
+
inputTokens: liveInputTokens,
|
|
947
|
+
outputTokens: liveOutputTokens,
|
|
948
|
+
cumulativeTotalTokens: finalCumulativeTotalTokens,
|
|
949
|
+
cumulativeInputTokens: finalCumulativeInputTokens,
|
|
950
|
+
cumulativeOutputTokens: finalCumulativeOutputTokens,
|
|
951
|
+
errorMsg: errorMessage(err),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
getLogger().info(`[bridge] skip error finish: state already terminal (${catchTerminal}) runId=${runId}`);
|
|
956
|
+
if (sawResult) {
|
|
957
|
+
await cardSession.finish(catchTerminal, {
|
|
958
|
+
contextLength: finalContextLength,
|
|
959
|
+
compactCount: finalCompactCount,
|
|
960
|
+
cacheReadTokens: finalCacheReadTokens,
|
|
961
|
+
cacheCreationTokens: finalCacheCreationTokens,
|
|
962
|
+
totalTokens: finalTotalTokens,
|
|
963
|
+
inputTokens: finalInputTokens,
|
|
964
|
+
outputTokens: finalOutputTokens,
|
|
965
|
+
cumulativeTotalTokens: finalCumulativeTotalTokens,
|
|
966
|
+
cumulativeInputTokens: finalCumulativeInputTokens,
|
|
967
|
+
cumulativeOutputTokens: finalCumulativeOutputTokens,
|
|
968
|
+
cumulativeCacheReadTokens: finalCumulativeCacheReadTokens,
|
|
969
|
+
cumulativeCacheCreationTokens: finalCumulativeCacheCreationTokens,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
finally {
|
|
975
|
+
if (idleInterval)
|
|
976
|
+
clearInterval(idleInterval);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Step 3 of forwardToClaude: Finalize the run.
|
|
981
|
+
* Handles fallback card sending, completion notification, and cleanup.
|
|
982
|
+
*/
|
|
983
|
+
async finalizeRun(cardSession, activeRun, ctx, cwd) {
|
|
984
|
+
const runId = activeRun.runId;
|
|
985
|
+
try {
|
|
986
|
+
getLogger().debug(`[bridge] finally settle runId=${runId} state.terminal=${cardSession.currentState.terminal}`);
|
|
987
|
+
const settleResult = await cardSession.settle();
|
|
988
|
+
let runCardSent = false;
|
|
989
|
+
if (settleResult === 'unsent') {
|
|
990
|
+
const sent = await this.sendResult({
|
|
991
|
+
card: renderRunCard(cardSession.currentState, {
|
|
992
|
+
showThinking: this.config.output.showThinking,
|
|
993
|
+
showToolUse: this.config.output.showToolUse,
|
|
994
|
+
showToolResult: this.config.output.showToolResult,
|
|
995
|
+
agentKind: activeRun.agentKind,
|
|
996
|
+
}),
|
|
997
|
+
}, ctx);
|
|
998
|
+
runCardSent = sent;
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
// streaming succeeded: run card is the last message in the chat
|
|
1002
|
+
// runCardSent stays false - streaming card exists but no new card sent
|
|
1003
|
+
}
|
|
1004
|
+
// Send a notification card when a fallback card was sent (unsent case)
|
|
1005
|
+
// but NOT when streaming succeeded (card already shows completion).
|
|
1006
|
+
const sessionId = cardSession.currentState.sessionId;
|
|
1007
|
+
if (sessionId && runCardSent) {
|
|
1008
|
+
await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind);
|
|
1009
|
+
}
|
|
1010
|
+
// Add a terminal-state emoji reaction to the user's original message
|
|
1011
|
+
void this.connector.addReaction(ctx.messageId, terminalReactionEmoji(cardSession.currentState.terminal));
|
|
1012
|
+
}
|
|
1013
|
+
finally {
|
|
1014
|
+
// P1-13: cleanup must survive mid-finalize errors. The only unguarded
|
|
1015
|
+
// expression in finalizeRun is the renderRunCard(...) argument evaluated
|
|
1016
|
+
// at the sendResult call site; if it throws, the run must still release
|
|
1017
|
+
// its activeRuns slot — otherwise the workspace is permanently busy
|
|
1018
|
+
// (every later message busy-dropped until a manual /stop).
|
|
1019
|
+
if (this.activeRuns.get(cwd) === activeRun) {
|
|
1020
|
+
this.activeRuns.delete(cwd);
|
|
1021
|
+
// Also clean up the runner to prevent memory leak
|
|
1022
|
+
// Fix 4: Clean up this specific (cwd, agentKind) runner slot.
|
|
1023
|
+
// (P1-1: eviction must also unregister the runner from the exit dispatcher)
|
|
1024
|
+
// P2-3: evict by the run's captured agentKind, not the live
|
|
1025
|
+
// defaultAgent — switching agent mid-run (via /config, whose
|
|
1026
|
+
// clearRunners skips the active cwd) would otherwise delete the new
|
|
1027
|
+
// agent's slot (no-op) and leave the original runner lingering.
|
|
1028
|
+
this.evictRunnerSlot(cwd, activeRun.agentKind);
|
|
1029
|
+
getLogger().info(`[bridge] activeRuns.delete cwd=${cwd} runId=${runId}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Read final usage (contextLength + compactCount + cache tokens) from the session jsonl.
|
|
1035
|
+
* Called after a run finishes: live stream-json does not emit compact_boundary,
|
|
1036
|
+
* so the live contextLength (fallback to result.usage input+output) is
|
|
1037
|
+
* unreliable. The jsonl is authoritative for postTokens + compact event count.
|
|
1038
|
+
*/
|
|
1039
|
+
resolveFinalUsage(sessionId, cwd, agentKind = this.config.defaultAgent) {
|
|
1040
|
+
if (!sessionId)
|
|
1041
|
+
return undefined;
|
|
1042
|
+
try {
|
|
1043
|
+
const content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
|
|
1044
|
+
if (!content.usage) {
|
|
1045
|
+
// EnterWorktree relocate (2026-08-04): jsonl read silently returned no
|
|
1046
|
+
// usage (e.g. transcript moved mid-session). Surface it so token-stat
|
|
1047
|
+
// fallback to per-run live usage is visible in logs, not silent.
|
|
1048
|
+
getLogger().warn(`[bridge] resolveFinalUsage: no usage from jsonl sessionId=${sessionId} cwd=${cwd} agent=${agentKind}, card falls back to per-run live usage`);
|
|
1049
|
+
}
|
|
1050
|
+
return content.usage
|
|
1051
|
+
? {
|
|
1052
|
+
contextLength: content.usage.contextLength,
|
|
1053
|
+
compactCount: content.usage.compactCount,
|
|
1054
|
+
cacheReadTokens: content.usage.cacheReadTokens,
|
|
1055
|
+
cacheCreationTokens: content.usage.cacheCreationTokens,
|
|
1056
|
+
totalTokens: content.usage.totalTokens,
|
|
1057
|
+
inputTokens: content.usage.inputTokens,
|
|
1058
|
+
outputTokens: content.usage.outputTokens,
|
|
1059
|
+
cumulativeTotalTokens: content.usage.cumulativeTotalTokens,
|
|
1060
|
+
cumulativeInputTokens: content.usage.cumulativeInputTokens,
|
|
1061
|
+
cumulativeOutputTokens: content.usage.cumulativeOutputTokens,
|
|
1062
|
+
cumulativeCacheReadTokens: content.usage.cumulativeCacheReadTokens,
|
|
1063
|
+
cumulativeCacheCreationTokens: content.usage.cumulativeCacheCreationTokens,
|
|
1064
|
+
}
|
|
1065
|
+
: undefined;
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
getLogger().warn(`[bridge] resolveFinalUsage failed sessionId=${sessionId}: ${errorMessage(err)}`);
|
|
1069
|
+
return undefined;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Send a notification card when a run completes.
|
|
1074
|
+
* This card contains the session summary and a button to resume the session.
|
|
1075
|
+
* It's sent as a new message (not updating the run card) so users can easily
|
|
1076
|
+
* find and return to this session without scrolling up.
|
|
1077
|
+
*/
|
|
1078
|
+
async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent) {
|
|
1079
|
+
try {
|
|
1080
|
+
// P1-21: cap the events read for the notification card — without
|
|
1081
|
+
// maxEvents claude returns every event after the last user message and
|
|
1082
|
+
// the card blows past Feishu's 28KB budget.
|
|
1083
|
+
const content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd, {
|
|
1084
|
+
maxEvents: COMPLETION_NOTIFICATION_MAX_EVENTS,
|
|
1085
|
+
});
|
|
1086
|
+
const { events, usage } = content;
|
|
1087
|
+
const elements = [
|
|
1088
|
+
markdownDiv(`📂 \`${cwd}\`\n会话: **${sessionId}**`),
|
|
1089
|
+
{ tag: 'hr' },
|
|
1090
|
+
];
|
|
1091
|
+
// Add session content in collapsible panels (last 2 expanded)
|
|
1092
|
+
events.forEach((ev, i) => {
|
|
1093
|
+
elements.push(sessionEventPanel(ev, i, events.length, 2, agentKind));
|
|
1094
|
+
});
|
|
1095
|
+
// Add usage stats at the end
|
|
1096
|
+
const usageStatsStr = formatUsageStats(usage, { showResult: true, result: 'completed' });
|
|
1097
|
+
if (usage) {
|
|
1098
|
+
elements.push(markdownDiv(usageStatsStr));
|
|
1099
|
+
}
|
|
1100
|
+
// Add action button to resume this session - use column_set+column for 2.0
|
|
1101
|
+
elements.push({
|
|
1102
|
+
tag: 'column_set',
|
|
1103
|
+
columns: [
|
|
1104
|
+
{
|
|
1105
|
+
tag: 'column',
|
|
1106
|
+
width: 'auto',
|
|
1107
|
+
elements: [
|
|
1108
|
+
{
|
|
1109
|
+
tag: 'button',
|
|
1110
|
+
text: { tag: 'plain_text', content: '🔁 切换到此会话' },
|
|
1111
|
+
type: 'primary',
|
|
1112
|
+
behaviors: [{ type: 'callback', value: { cmd: 'resume.use', sessionId } }],
|
|
1113
|
+
},
|
|
1114
|
+
],
|
|
1115
|
+
},
|
|
1116
|
+
],
|
|
1117
|
+
});
|
|
1118
|
+
const card = {
|
|
1119
|
+
schema: '2.0',
|
|
1120
|
+
config: { wide_screen_mode: true },
|
|
1121
|
+
header: {
|
|
1122
|
+
template: 'green',
|
|
1123
|
+
title: {
|
|
1124
|
+
tag: 'plain_text',
|
|
1125
|
+
content: '✅ 会话已完成',
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
body: { elements },
|
|
1129
|
+
};
|
|
1130
|
+
// P1-21: send through sendResult so enforceCardBudget guards the card
|
|
1131
|
+
// (previously sent via connector directly, bypassing the budget — long
|
|
1132
|
+
// sessions produced >28KB cards that Feishu silently rejected).
|
|
1133
|
+
const sent = await this.sendResult({ card }, ctx);
|
|
1134
|
+
if (sent) {
|
|
1135
|
+
getLogger().debug(`[bridge] completion notification sent sessionId=${sessionId} chatId=${ctx.chatId}`);
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
getLogger().warn(`[bridge] failed to send completion notification card sessionId=${sessionId}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
catch (err) {
|
|
1142
|
+
getLogger().warn(`[bridge] failed to send completion notification card:`, err);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Execute a bash command and stream output to a card.
|
|
1147
|
+
* `!` commands bypass the serial queue entirely (see body comment + §9.6):
|
|
1148
|
+
* bash runs in parallel with same-workspace agent runs, tracked in
|
|
1149
|
+
* `activeBashRuns` separately from `activeRuns`.
|
|
1150
|
+
*/
|
|
1151
|
+
async executeBash(command, ctx) {
|
|
1152
|
+
const cwd = this.resolveCwd(ctx.userId);
|
|
1153
|
+
getLogger().info(`[bridge] executeBash start userId=${ctx.userId} sessionCwd=${this.sessionStore.get(ctx.userId)?.cwd} command="${command.slice(0, 30)}..."`);
|
|
1154
|
+
if (!cwd) {
|
|
1155
|
+
getLogger().info(`[bridge] executeBash no cwd, sending error`);
|
|
1156
|
+
await this.sendResult({ text: '请先使用 /cd <path> 设置工作目录,或保存一个 workspace (/ws save <name>)' }, ctx);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
getLogger().info(`[bridge] executeBash proceeding cwd=${cwd}`);
|
|
1160
|
+
// `!` commands bypass the serial queue entirely (design.md §9.6):
|
|
1161
|
+
// index.ts dispatches `!` to router.handle → executeBash directly, NOT via
|
|
1162
|
+
// bridge.enqueue. bash runs in parallel with same-workspace claude runs.
|
|
1163
|
+
// bash runs are tracked in `activeBashRuns` (keyed by runId), separate from
|
|
1164
|
+
// claude's `activeRuns` (keyed by cwd) — they must not collide or `/stop`
|
|
1165
|
+
// would kill the wrong task. Do NOT re-chain onto the serial queue
|
|
1166
|
+
// (QueueManager) here: that would block on the still-pending outer promise
|
|
1167
|
+
// and deadlock self-wait.
|
|
1168
|
+
await this.executeBashInternal(command, ctx, cwd);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Internal execution of bash command (called directly from executeBash,
|
|
1172
|
+
* bypassing the serial queue — see executeBash).
|
|
1173
|
+
*/
|
|
1174
|
+
async executeBashInternal(command, ctx, cwd) {
|
|
1175
|
+
getLogger().debug(`[bridge] executeBashInternal userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} command="${command.slice(0, 50)}..."`);
|
|
1176
|
+
// Note: We don't check activeRuns here because bash bypasses the serial
|
|
1177
|
+
// queue and runs in parallel with same-workspace agent runs (§9.6). The
|
|
1178
|
+
// activeRuns check in forwardToClaude is for a different purpose (detecting
|
|
1179
|
+
// concurrent agent runs that would corrupt session state). Bash commands
|
|
1180
|
+
// don't have that issue.
|
|
1181
|
+
const runId = randomUUID();
|
|
1182
|
+
const bashRunner = new BashProcessRunner();
|
|
1183
|
+
// Single streaming card across the whole run (initial → output patches →
|
|
1184
|
+
// terminal), instead of sending many independent cards. BashCardSession is
|
|
1185
|
+
// the bash analogue of RunCardSession.
|
|
1186
|
+
const cardSession = new BashCardSession({
|
|
1187
|
+
connector: this.connector,
|
|
1188
|
+
chatId: ctx.chatId,
|
|
1189
|
+
replyTo: ctx.messageId,
|
|
1190
|
+
runId,
|
|
1191
|
+
command,
|
|
1192
|
+
renderOptions: {},
|
|
1193
|
+
});
|
|
1194
|
+
// Track this bash run independently from claude's `activeRuns`. Bash runs
|
|
1195
|
+
// bypass the serial queue and may run in parallel with a claude run in the
|
|
1196
|
+
// same workspace, so they must NOT share activeRuns (which is keyed by cwd
|
|
1197
|
+
// and would let bash/claude overwrite each other). Keyed by runId so
|
|
1198
|
+
// multiple `!` commands can run concurrently. Tracked so /stop can reach
|
|
1199
|
+
// the bashRunner (which is otherwise a local) via interruptCurrentRun.
|
|
1200
|
+
this.activeBashRuns.set(runId, {
|
|
1201
|
+
bashRunner,
|
|
1202
|
+
cardSession,
|
|
1203
|
+
userId: ctx.userId,
|
|
1204
|
+
chatId: ctx.chatId,
|
|
1205
|
+
cwd,
|
|
1206
|
+
command,
|
|
1207
|
+
});
|
|
1208
|
+
let output = '';
|
|
1209
|
+
let stderr = '';
|
|
1210
|
+
let exitCode = null;
|
|
1211
|
+
try {
|
|
1212
|
+
getLogger().info(`[bridge] executeBashInternal about to start stream chatId=${ctx.chatId} messageId=${ctx.messageId}`);
|
|
1213
|
+
try {
|
|
1214
|
+
await cardSession.start();
|
|
1215
|
+
getLogger().info(`[bridge] executeBash stream started runId=${runId}`);
|
|
1216
|
+
}
|
|
1217
|
+
catch (err) {
|
|
1218
|
+
getLogger().warn('[bridge] bash card stream unavailable:', err);
|
|
1219
|
+
}
|
|
1220
|
+
for await (const event of bashRunner.run(command, { cwd })) {
|
|
1221
|
+
if (event.type === 'stdout') {
|
|
1222
|
+
// P1-4 层④:bridge 本地 output 也必须 store-time 截断——仅 session state
|
|
1223
|
+
// 有界并不会让 `output +=` 自然有界(review 第④条表述不成立),!yes 洪峰
|
|
1224
|
+
// 下本地字符串仍全量驻留。capBashOutput 增量调用 O(CAP+chunk),不会带回
|
|
1225
|
+
// O(n²) 截断成本(output 恒 ≤ CAP,拼接后 ≤ CAP+chunk 再 slice 尾部)。
|
|
1226
|
+
output = capBashOutput(output + event.content);
|
|
1227
|
+
await cardSession.update({ output, stderr });
|
|
1228
|
+
}
|
|
1229
|
+
else if (event.type === 'stderr') {
|
|
1230
|
+
stderr = capBashOutput(stderr + event.content);
|
|
1231
|
+
await cardSession.update({ output, stderr });
|
|
1232
|
+
}
|
|
1233
|
+
else if (event.type === 'exit') {
|
|
1234
|
+
exitCode = event.exitCode ?? 0;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
await cardSession.finish(exitCode === 0 ? 'done' : 'error', { exitCode, output, stderr });
|
|
1238
|
+
}
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
getLogger().error(`[bridge] bash run error runId=${runId}:`, err);
|
|
1241
|
+
// If for-await threw mid-stream the bash child may still be running.
|
|
1242
|
+
// Stop it before finalizing the card — otherwise the finally block
|
|
1243
|
+
// removes the activeBashRuns entry and /stop can no longer reach it,
|
|
1244
|
+
// leaving an orphan process.
|
|
1245
|
+
try {
|
|
1246
|
+
await bashRunner.stop();
|
|
1247
|
+
}
|
|
1248
|
+
catch (stopErr) {
|
|
1249
|
+
getLogger().warn(`[bridge] bash stop failed:`, stopErr);
|
|
1250
|
+
}
|
|
1251
|
+
await cardSession.finish('error', {
|
|
1252
|
+
exitCode: 1,
|
|
1253
|
+
output,
|
|
1254
|
+
stderr: stderr + `\n执行错误: ${errorMessage(err)}`,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
finally {
|
|
1258
|
+
const settleResult = await cardSession.settle();
|
|
1259
|
+
if (settleResult === 'unsent') {
|
|
1260
|
+
// Stream never established a card — send a static terminal card as fallback.
|
|
1261
|
+
// Use 2.0 schema to match the streaming card (M2: avoid 2.0→1.x downgrade).
|
|
1262
|
+
try {
|
|
1263
|
+
await this.connector.sendWithRetry(ctx.chatId, { card: renderBashCard(cardSession.currentState, {}) }, { replyTo: ctx.messageId });
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
/* ignore fallback errors */
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
if (this.activeBashRuns.has(runId)) {
|
|
1270
|
+
this.activeBashRuns.delete(runId);
|
|
1271
|
+
getLogger().info(`[bridge] executeBash done runId=${runId} exitCode=${exitCode}`);
|
|
1272
|
+
}
|
|
1273
|
+
// Add a Done emoji reaction to the user's original message to signal completion
|
|
1274
|
+
void this.connector.addReaction(ctx.messageId, 'Done');
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
function errorMessage(error) {
|
|
1279
|
+
return error instanceof Error ? error.message : String(error);
|
|
1280
|
+
}
|
|
1281
|
+
//# sourceMappingURL=index.js.map
|