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,2715 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, } from '../config/index.js';
|
|
6
|
+
import { syncAgentChoices } from '../runner/index.js';
|
|
7
|
+
import { WorkspaceStore } from '../workspace/index.js';
|
|
8
|
+
import { OrderStore } from '../order/index.js';
|
|
9
|
+
import { getLogger } from '../logger/index.js';
|
|
10
|
+
import { activeRunUsage, formatUsageStats } from './utils.js';
|
|
11
|
+
import { markdownDiv, sessionEventPanel } from './card-helpers.js';
|
|
12
|
+
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
13
|
+
import { atomicWrite } from '../persistence/atomic-write.js';
|
|
14
|
+
/** Config card builder - delegates to per-agent builders */
|
|
15
|
+
import { getConfigBuilder, listRegisteredAgents } from './config/index.js';
|
|
16
|
+
import { buildConfigCardFromTabs } from './config/common/render.js';
|
|
17
|
+
/** Max events to read for auto-resume and /resume cards.
|
|
18
|
+
* Limits events at the read stage so enforceCardBudget rarely needs to truncate.
|
|
19
|
+
* Chosen to be under the 28KB card budget for typical events (~2KB each). */
|
|
20
|
+
const AUTO_RESUME_MAX_EVENTS = 5;
|
|
21
|
+
/** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
|
|
22
|
+
const RESUME_PAGE_SIZE = 5;
|
|
23
|
+
/** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
|
|
24
|
+
const RESUME_CONTENT_PREFETCH = 5;
|
|
25
|
+
/**
|
|
26
|
+
* Reader summary 占位符(trim 后匹配)。各 reader 在"无 user 消息"时返回的
|
|
27
|
+
* 兜底文案不同(claude/pi `(无摘要)`、codex `(no user message)`、kimi
|
|
28
|
+
* `New Session`、空串),都不是真实输入,不得渲染成行标题。
|
|
29
|
+
*/
|
|
30
|
+
const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
|
|
31
|
+
import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
|
|
32
|
+
/**
|
|
33
|
+
* Card-action commands that must run with enqueue({ immediate: true }).
|
|
34
|
+
* These bypass the serial queue and execute immediately.
|
|
35
|
+
*
|
|
36
|
+
* ws.use — /ws list card "使用 <name>"
|
|
37
|
+
* queue.immediate — queue card "立即执行" button (§9.6)
|
|
38
|
+
* queue.cancel — queue card "撤销" button
|
|
39
|
+
* queue.diagnose — queue card "诊断" button
|
|
40
|
+
*
|
|
41
|
+
* §9.19: Extended to cover all control-only actions that never spawn Claude:
|
|
42
|
+
* new-session — clear sessionId only
|
|
43
|
+
* stop — interrupt current run
|
|
44
|
+
* ls.file — send file only
|
|
45
|
+
* ws.remove — delete workspace alias only
|
|
46
|
+
* resume.use — set sessionId only
|
|
47
|
+
* help.* — read-only help commands
|
|
48
|
+
* order.delete — delete order only
|
|
49
|
+
* order.exec is intercepted by index.ts at the enqueue boundary
|
|
50
|
+
* (resolveOrderExecForQueue → router.handle), never reaching this dispatcher.
|
|
51
|
+
*/
|
|
52
|
+
export function isImmediateAction(cmd) {
|
|
53
|
+
// help.* wildcard: any command starting with "help."
|
|
54
|
+
if (cmd.startsWith('help.'))
|
|
55
|
+
return true;
|
|
56
|
+
return (cmd === 'new-session' ||
|
|
57
|
+
cmd === 'stop' ||
|
|
58
|
+
cmd === 'ls.file' ||
|
|
59
|
+
cmd === 'ls.refresh' ||
|
|
60
|
+
cmd === 'ls.browse' ||
|
|
61
|
+
cmd === 'ls.switch' ||
|
|
62
|
+
cmd === 'ls.page' ||
|
|
63
|
+
cmd === 'resume.page' || // control operation: paginate only, never spawns claude
|
|
64
|
+
cmd === 'ws.remove' ||
|
|
65
|
+
cmd === 'resume.use' ||
|
|
66
|
+
cmd === 'ws.use' ||
|
|
67
|
+
cmd === 'queue.immediate' ||
|
|
68
|
+
cmd === 'queue.cancel' ||
|
|
69
|
+
cmd === 'queue.diagnose' ||
|
|
70
|
+
cmd === 'queue.edit' ||
|
|
71
|
+
cmd === 'queue.input' ||
|
|
72
|
+
cmd === 'order.delete' ||
|
|
73
|
+
cmd === 'config.toggle' ||
|
|
74
|
+
cmd === 'config.set' ||
|
|
75
|
+
cmd === 'config.input' ||
|
|
76
|
+
cmd === 'config.save');
|
|
77
|
+
}
|
|
78
|
+
export class CommandRouter {
|
|
79
|
+
sessionStore;
|
|
80
|
+
bridge;
|
|
81
|
+
config;
|
|
82
|
+
configPath;
|
|
83
|
+
workspaceStore;
|
|
84
|
+
orderStore;
|
|
85
|
+
exitHandler;
|
|
86
|
+
pendingExit = false;
|
|
87
|
+
/**
|
|
88
|
+
* Spawns the detached replacement bridge process (same argv/config dir) and
|
|
89
|
+
* returns its pid. Injected from index.ts; /restart is unavailable without it.
|
|
90
|
+
*/
|
|
91
|
+
restartSpawner;
|
|
92
|
+
idleTimeoutMs;
|
|
93
|
+
pendingConfig = null; // /config 卡片编辑暂存区
|
|
94
|
+
/**
|
|
95
|
+
* Monotonic counter minting unique internal keys for order.exec enqueue
|
|
96
|
+
* actions. One order card can be clicked many times, so the Feishu card
|
|
97
|
+
* messageId is 1:N with enqueue actions and must not be reused as the queue
|
|
98
|
+
* dedup key. Date.now() alone is unsafe (two synchronous calls can share a
|
|
99
|
+
* millisecond); the counter guarantees uniqueness regardless of timing.
|
|
100
|
+
*/
|
|
101
|
+
orderExecKeyCounter = 0;
|
|
102
|
+
/**
|
|
103
|
+
* Session reader registry for multi-agent path. Resolves the right
|
|
104
|
+
* reader by `config.defaultAgent` (and per-agent override params).
|
|
105
|
+
* NOTE: `/config` card's `defaultAgent` selector does NOT use this —
|
|
106
|
+
* it uses `listRegisteredAgents()` from `router/config/index.ts` (the
|
|
107
|
+
* config-builder registry), which mirrors the agents with a config card.
|
|
108
|
+
*/
|
|
109
|
+
sessionReaderRegistry;
|
|
110
|
+
/**
|
|
111
|
+
* 串行化 config.* 卡片回调(2026-07-04)。
|
|
112
|
+
* CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,**不进串行队列**,
|
|
113
|
+
* 多次快速点击 toggle/input 会并发读写 `pendingConfig` 并同时调
|
|
114
|
+
* `updateCardInPlace` → Feishu API 乱序到达,导致 toggle 卡死在某个状态。
|
|
115
|
+
* 此 Promise chain 把所有 config.* 动作串成一个执行链,保证后到的动作
|
|
116
|
+
* 等前一个动作的卡片 patch 完成后再读 `pendingConfig`。
|
|
117
|
+
*/
|
|
118
|
+
configActionQueue = Promise.resolve();
|
|
119
|
+
constructor(opts) {
|
|
120
|
+
this.sessionStore = opts.sessionStore;
|
|
121
|
+
this.bridge = opts.bridge;
|
|
122
|
+
this.config = opts.config;
|
|
123
|
+
this.configPath = opts.configPath;
|
|
124
|
+
this.workspaceStore = new WorkspaceStore(opts.workspacePath);
|
|
125
|
+
this.orderStore = new OrderStore(opts.ordersPath);
|
|
126
|
+
this.exitHandler = opts.exitHandler ?? (() => process.exit(0));
|
|
127
|
+
this.restartSpawner = opts.restartSpawner;
|
|
128
|
+
// idle watchdog 窗口从 config.idle.watchdogMinutes 读取
|
|
129
|
+
// opts.idleTimeoutMs 仅供测试覆盖。
|
|
130
|
+
this.idleTimeoutMs = opts.idleTimeoutMs ?? opts.config.idle.watchdogMinutes * 60_000;
|
|
131
|
+
if (this.bridge) {
|
|
132
|
+
this.bridge.setIdleTimeout(this.idleTimeoutMs);
|
|
133
|
+
}
|
|
134
|
+
this.sessionReaderRegistry = opts.sessionReaderRegistry;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the active session reader for the current defaultAgent.
|
|
138
|
+
* The reader is fetched from the registry each time to reflect config changes.
|
|
139
|
+
*/
|
|
140
|
+
get sessionReader() {
|
|
141
|
+
return this.sessionReaderRegistry.get(this.config.defaultAgent);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Route a message: if it starts with /, handle as command; if it starts with !, execute as bash; otherwise forward to Claude.
|
|
145
|
+
*/
|
|
146
|
+
async handle(message, ctx, opts) {
|
|
147
|
+
const trimmed = message.trim();
|
|
148
|
+
const startsWithBang = trimmed.startsWith('!');
|
|
149
|
+
const startsWithSlash = trimmed.startsWith('/');
|
|
150
|
+
getLogger().info(`[router] handle message="${message.slice(0, 50)}..." trimmed="${trimmed.slice(0, 50)}..." startsWithSlash=${startsWithSlash} startsWithBang=${startsWithBang}`);
|
|
151
|
+
if (startsWithSlash) {
|
|
152
|
+
const result = await this.executeCommand(trimmed, ctx);
|
|
153
|
+
if (result) {
|
|
154
|
+
await this.bridge.sendResult(result, ctx);
|
|
155
|
+
}
|
|
156
|
+
if (this.pendingExit) {
|
|
157
|
+
this.exitHandler();
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
// Handle bang commands (!command)
|
|
162
|
+
if (trimmed.startsWith('!')) {
|
|
163
|
+
const cmd = trimmed.slice(1).trim();
|
|
164
|
+
if (!cmd) {
|
|
165
|
+
await this.bridge.sendResult({ text: '请输入要执行的命令,例如 !ls' }, ctx);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
await this.bridge.executeBash(cmd, ctx);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
// Forward to Claude (P1-14: pass the enqueue-time workspace through so the
|
|
172
|
+
// run uses the same cwd as the serial queue lane, even if /cd ran while
|
|
173
|
+
// the message was queued)
|
|
174
|
+
await this.bridge.forwardToClaude(trimmed, ctx, opts);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Handle a CardKit card button click (§6.2).
|
|
179
|
+
* Validates payload and dispatches to the corresponding command.
|
|
180
|
+
*/
|
|
181
|
+
async handleCardAction(value, ctx) {
|
|
182
|
+
switch (value.cmd) {
|
|
183
|
+
case 'ls.file':
|
|
184
|
+
await this.cardLsFile(value.path, ctx);
|
|
185
|
+
return;
|
|
186
|
+
case 'ls.refresh':
|
|
187
|
+
await this.handleLsRefresh(value, ctx);
|
|
188
|
+
return;
|
|
189
|
+
case 'ls.browse':
|
|
190
|
+
await this.handleLsBrowse(value, ctx);
|
|
191
|
+
return;
|
|
192
|
+
case 'ls.switch':
|
|
193
|
+
await this.handleLsSwitch(value, ctx);
|
|
194
|
+
return;
|
|
195
|
+
case 'ls.page':
|
|
196
|
+
return this.handleLsPage(value, ctx);
|
|
197
|
+
case 'ws.use':
|
|
198
|
+
await this.bridge.sendResult(this.cmdWs(['use', value.name ?? ''], ctx), ctx);
|
|
199
|
+
return;
|
|
200
|
+
case 'ws.remove':
|
|
201
|
+
return await this.handleWsRemove(value, ctx);
|
|
202
|
+
case 'resume.use': {
|
|
203
|
+
// resume.use can carry an agent field when called from /resume [agent] list
|
|
204
|
+
const resumeAgent = value.agent ?? this.config.defaultAgent;
|
|
205
|
+
// Validate agent is a valid type
|
|
206
|
+
const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
|
|
207
|
+
const resolvedAgent = validAgents.includes(resumeAgent)
|
|
208
|
+
? resumeAgent
|
|
209
|
+
: this.config.defaultAgent;
|
|
210
|
+
const entry = this.sessionStore.get(ctx.userId);
|
|
211
|
+
const cwd = entry?.cwd;
|
|
212
|
+
if (!cwd) {
|
|
213
|
+
await this.bridge.sendResult({ text: '请先 /cd 设置工作目录' }, ctx);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// P1-5:校验前不得写入 sessionId。旧实现先无条件 setSessionId 再调
|
|
217
|
+
// cmdResume 校验,过期卡片(session 不在当前 cwd)校验失败时 store 已被
|
|
218
|
+
// 污染;value.sessionId 缺失时还会静默清空已有绑定。现在统一走 cmdResume
|
|
219
|
+
// 的已验证路径(:2352 校验通过才写入),sessionId 缺失直接报错。
|
|
220
|
+
if (!value.sessionId) {
|
|
221
|
+
await this.bridge.sendResult({ text: '缺少 sessionId,请重新从 /resume 列表选择会话' }, ctx);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
await this.bridge.sendResult(this.cmdResume([resolvedAgent, value.sessionId], ctx), ctx);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
case 'resume.page':
|
|
228
|
+
return this.handleResumePage(value, ctx);
|
|
229
|
+
case 'new-session':
|
|
230
|
+
this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
|
|
231
|
+
clearSessionCwd: true,
|
|
232
|
+
});
|
|
233
|
+
{
|
|
234
|
+
const cwd = this.sessionStore.getCwd(ctx.userId) ?? '(未设置)';
|
|
235
|
+
const agentName = agentDisplayName(this.config.defaultAgent);
|
|
236
|
+
await this.bridge.sendResult({ text: `已创建新 ${agentName} 会话,下一条消息将开始全新对话\n📁 ${cwd}` }, ctx);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
case 'stop':
|
|
240
|
+
await this.handleCardStop(value, ctx);
|
|
241
|
+
return;
|
|
242
|
+
case 'queue.cancel':
|
|
243
|
+
// 撤销:从队列中删除这个消息
|
|
244
|
+
await this.handleQueueCancel(value, ctx);
|
|
245
|
+
return;
|
|
246
|
+
case 'queue.immediate':
|
|
247
|
+
// 立即执行:停止当前进程,删除队列中这个消息之前的全部消息,立即执行这一条
|
|
248
|
+
await this.handleQueueImmediate(value, ctx);
|
|
249
|
+
return;
|
|
250
|
+
case 'queue.diagnose':
|
|
251
|
+
// 诊断:切换到项目目录,启动 Claude -p 定位问题
|
|
252
|
+
await this.handleQueueDiagnose(value, ctx);
|
|
253
|
+
return;
|
|
254
|
+
case 'queue.edit':
|
|
255
|
+
// 编辑:显示输入框让用户修改消息内容
|
|
256
|
+
await this.handleQueueEdit(value, ctx);
|
|
257
|
+
return;
|
|
258
|
+
case 'queue.input':
|
|
259
|
+
// 输入:提交新消息内容。返回 toast(SDK 作为飞书回调响应给点击用户即时反馈)。
|
|
260
|
+
return await this.handleQueueInput(value, ctx);
|
|
261
|
+
case 'order.delete':
|
|
262
|
+
return await this.handleOrderDelete(value, ctx);
|
|
263
|
+
case 'config.toggle':
|
|
264
|
+
case 'config.set':
|
|
265
|
+
case 'config.input':
|
|
266
|
+
case 'config.save':
|
|
267
|
+
// 2026-07-04: 串行化所有 config.* 动作。CardKit 2.0 回调经 enqueueImmediate
|
|
268
|
+
// 不进串行队列,多次快速点击 toggle/input 会并发读写 pendingConfig 并同时
|
|
269
|
+
// 调 updateCardInPlace → Feishu API 乱序到达导致 toggle 卡死。
|
|
270
|
+
// 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
|
|
271
|
+
return this.enqueueConfigAction(value, ctx);
|
|
272
|
+
default: {
|
|
273
|
+
// Help card buttons: help.<cmd> → execute /<cmd>
|
|
274
|
+
if (value.cmd.startsWith('help.')) {
|
|
275
|
+
const subCmd = value.cmd.slice(5); // e.g. "status", "ps", "stop"
|
|
276
|
+
const result = await this.executeCommand(`/${subCmd}`, ctx);
|
|
277
|
+
if (result) {
|
|
278
|
+
await this.bridge.sendResult(result, ctx);
|
|
279
|
+
}
|
|
280
|
+
// /exit、/restart 等命令通过 pendingExit 表达"回复送达后退出"。
|
|
281
|
+
// handle() 在 sendResult 后消费它;help.* 按钮点击走 handleCardAction,
|
|
282
|
+
// 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
|
|
283
|
+
// 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
|
|
284
|
+
if (this.pendingExit) {
|
|
285
|
+
this.exitHandler();
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// AGENTS.md red-line: miss paths must reply via bridge.sendResult —
|
|
290
|
+
// don't silently swallow unknown card actions.
|
|
291
|
+
getLogger().warn(`[router] unknown card action: ${value.cmd}`);
|
|
292
|
+
await this.bridge.sendResult({ text: `⚠️ 未知的卡片操作: ${value.cmd}` }, ctx);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Handle queue.cancel: remove this message from the queue.
|
|
299
|
+
*/
|
|
300
|
+
async handleQueueCancel(value, ctx) {
|
|
301
|
+
const workspace = value.workspace;
|
|
302
|
+
const messageId = value.messageId;
|
|
303
|
+
if (!workspace || !messageId) {
|
|
304
|
+
await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const removed = this.bridge.removeFromQueue(workspace, messageId);
|
|
308
|
+
if (removed) {
|
|
309
|
+
// Update the queue card in-place to "cancelled" state
|
|
310
|
+
await this.bridge.updateQueueCardToCancelled(workspace, messageId);
|
|
311
|
+
// Send a brief confirmation (user can see the card has been updated)
|
|
312
|
+
await this.bridge.sendResult({ text: '✅ 已从队列中撤销' }, ctx);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
await this.bridge.sendResult({ text: '⚠️ 该消息不在队列中(可能已开始执行)' }, ctx);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Handle queue.immediate: stop current run, clear queue before this message, execute this message immediately.
|
|
320
|
+
* Instead of asking user to resend, we keep the target task in queue and let it execute.
|
|
321
|
+
*/
|
|
322
|
+
async handleQueueImmediate(value, ctx) {
|
|
323
|
+
const workspace = value.workspace;
|
|
324
|
+
const messageId = value.messageId;
|
|
325
|
+
if (!workspace || !messageId) {
|
|
326
|
+
await this.bridge.sendResult({ text: '卡片 payload 缺少必要信息' }, ctx);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// 1. Check target exists BEFORE any await. The queue chain can advance
|
|
330
|
+
// while later awaits (interruptCurrentRun / markQueueCardExecuting's card
|
|
331
|
+
// send) are in flight, so the target must be read synchronously at entry.
|
|
332
|
+
const targetTask = this.bridge.getQueuedTask(workspace, messageId);
|
|
333
|
+
if (!targetTask) {
|
|
334
|
+
// The target already began (or was cancelled) before this handler ran. We
|
|
335
|
+
// must NOT claim the queue was cleared — nothing is removed in this branch
|
|
336
|
+
// and tasks queued behind the target are still waiting (see A13).
|
|
337
|
+
await this.bridge.sendResult({
|
|
338
|
+
text: '该消息已不在队列中(可能已开始执行或被撤销),无法立即执行。其余排队消息保持原状。',
|
|
339
|
+
}, ctx);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// 1.5. If the user edited the message, the original closure (captured at
|
|
343
|
+
// enqueue time with stale content) must NOT run. Register a one-shot
|
|
344
|
+
// replacement closure on the task's EXISTING queue slot BEFORE any await:
|
|
345
|
+
// interruptCurrentRun / markQueueCardExecuting can let the chain advance to
|
|
346
|
+
// this task's begin, and begin consumes the replacement at that point — a
|
|
347
|
+
// registration after those awaits would arrive too late (the stale closure
|
|
348
|
+
// runs and the replacement leaks as a permanent dead closure). Registering
|
|
349
|
+
// on the existing slot (instead of removeFromQueue + re-enqueue) preserves
|
|
350
|
+
// the queue position: enqueue can only append to the promise chain tail,
|
|
351
|
+
// which would put the edited task behind tasks queued after it.
|
|
352
|
+
if (targetTask.editedMessage) {
|
|
353
|
+
const editedContent = targetTask.editedMessage;
|
|
354
|
+
// D3/Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
|
|
355
|
+
// cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
|
|
356
|
+
this.bridge.setTaskReplacement(workspace, messageId, async () => {
|
|
357
|
+
await this.handle(editedContent, ctx, {
|
|
358
|
+
cwdOverride: workspace,
|
|
359
|
+
binding: targetTask.binding,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
// 2. Stop current running process
|
|
364
|
+
// (Note: queue executing count is reset inside Bridge.interruptCurrentRun
|
|
365
|
+
// so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
|
|
366
|
+
const stopped = await this.bridge.interruptCurrentRun({
|
|
367
|
+
userId: ctx.userId,
|
|
368
|
+
chatId: ctx.chatId,
|
|
369
|
+
workspace,
|
|
370
|
+
});
|
|
371
|
+
getLogger().info(`[router] queue.immediate: stopped=${stopped}`);
|
|
372
|
+
// 3. Remove all tasks BEFORE this one (by position in the current queue).
|
|
373
|
+
// The target may have begun while interruptCurrentRun was awaiting (the chain
|
|
374
|
+
// advances concurrently): then it is already out of the snapshot and every
|
|
375
|
+
// remaining queued task is BEHIND it — clearing must stop, not run off the
|
|
376
|
+
// end and delete tasks queued after the target.
|
|
377
|
+
const tasks = this.bridge.getQueuedTasks(workspace);
|
|
378
|
+
const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
|
|
379
|
+
let removedCount = 0;
|
|
380
|
+
const removedIds = [];
|
|
381
|
+
// Phase 1: remove synchronously. The queue chain advances on any await
|
|
382
|
+
// (card PATCH round trips included), so every task before the target must
|
|
383
|
+
// be removed before yielding to the event loop — otherwise a task the user
|
|
384
|
+
// asked to clear can begin executing while its cancellation card is still
|
|
385
|
+
// in flight.
|
|
386
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
387
|
+
const task = tasks[i];
|
|
388
|
+
if (this.bridge.removeFromQueue(workspace, task.messageId)) {
|
|
389
|
+
removedCount++;
|
|
390
|
+
removedIds.push(task.messageId);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (targetIdx < 0) {
|
|
394
|
+
getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
|
|
395
|
+
}
|
|
396
|
+
// Phase 2: update cancelled cards after all removals. All tasks before the
|
|
397
|
+
// target are already gone, so any chain advance can only reach the target
|
|
398
|
+
// itself — the card updates may safely run in the background.
|
|
399
|
+
for (const removedId of removedIds) {
|
|
400
|
+
void this.bridge.updateQueueCardToCancelled(workspace, removedId);
|
|
401
|
+
}
|
|
402
|
+
// 4. DO NOT remove the target task - keep it in queue to execute immediately
|
|
403
|
+
// The queue will naturally execute it after the current task (which we just stopped)
|
|
404
|
+
// 4.4. Refresh the replacement with the latest edited content (idempotent:
|
|
405
|
+
// re-registering over the same slot). Covers the user editing the message
|
|
406
|
+
// again while interruptCurrentRun was awaiting; skipped automatically once
|
|
407
|
+
// the task has begun, because getQueuedTask then returns undefined.
|
|
408
|
+
const latestTask = this.bridge.getQueuedTask(workspace, messageId);
|
|
409
|
+
if (latestTask?.editedMessage) {
|
|
410
|
+
const editedContent = latestTask.editedMessage;
|
|
411
|
+
// D3/Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
|
|
412
|
+
this.bridge.setTaskReplacement(workspace, messageId, async () => {
|
|
413
|
+
await this.handle(editedContent, ctx, {
|
|
414
|
+
cwdOverride: workspace,
|
|
415
|
+
binding: latestTask.binding,
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
// 4.5/6. The target must only be marked executing (and the toast may only
|
|
420
|
+
// promise immediate execution) when it is really the next task after the
|
|
421
|
+
// stop. While interruptCurrentRun was awaiting, a task ahead of the target
|
|
422
|
+
// may have begun and now occupy the workspace — the target is still queued
|
|
423
|
+
// but is NOT next, so its card must stay queued and the toast must say so.
|
|
424
|
+
const targetStillQueued = this.bridge.getQueuedTask(workspace, messageId) !== undefined;
|
|
425
|
+
if (!targetStillQueued) {
|
|
426
|
+
// The target began or was cancelled while earlier awaits were in flight —
|
|
427
|
+
// a success toast must never promise execution of a task that will not
|
|
428
|
+
// run. The two missing-reason states get distinct feedback: a target
|
|
429
|
+
// that began while the interrupt was in flight is already executing, so
|
|
430
|
+
// the toast must acknowledge that instead of telling the user nothing
|
|
431
|
+
// was scheduled; only a target that was actually cancelled (never began)
|
|
432
|
+
// gets the "未安排执行" wording.
|
|
433
|
+
if (this.bridge.hasTaskBegan(messageId)) {
|
|
434
|
+
await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
await this.bridge.sendResult({
|
|
438
|
+
text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
|
|
439
|
+
}, ctx);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const workspaceBusy = this.bridge.isBusyFor(workspace);
|
|
444
|
+
if (!workspaceBusy) {
|
|
445
|
+
// Normal path: nothing new began during the stop, the target is next.
|
|
446
|
+
// Mark its own card as executing now so buttons grey out immediately.
|
|
447
|
+
// Done BEFORE any removal so getQueuedTask still returns the task with
|
|
448
|
+
// its (possibly edited) messagePreview for the card. The queue callback's
|
|
449
|
+
// later updateQueueCardToExecuting call becomes a no-op (idempotent).
|
|
450
|
+
await this.bridge.markQueueCardExecuting(workspace, messageId);
|
|
451
|
+
const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
|
|
452
|
+
await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
await this.bridge.sendResult({
|
|
456
|
+
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
457
|
+
}, ctx);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Handle queue.diagnose: show diagnostic info for why message is queuing.
|
|
462
|
+
*/
|
|
463
|
+
async handleQueueDiagnose(value, ctx) {
|
|
464
|
+
const workspace = value.workspace;
|
|
465
|
+
const messageId = value.messageId;
|
|
466
|
+
const targetUserId = value.userId ?? ctx.userId;
|
|
467
|
+
if (!workspace || !messageId) {
|
|
468
|
+
await this.bridge.sendResult({ text: '卡片 payload 缺少必要信息' }, ctx);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const task = this.bridge.getQueuedTask(workspace, messageId);
|
|
472
|
+
const queueInfo = this.bridge.getQueueInfo(workspace);
|
|
473
|
+
const activeRuns = this.bridge.getAllActiveRuns();
|
|
474
|
+
// Build diagnostic info
|
|
475
|
+
const now = new Date().toLocaleString('zh-CN');
|
|
476
|
+
const pid = process.pid;
|
|
477
|
+
const sessionId = this.sessionStore.getSessionId(targetUserId, this.config.defaultAgent);
|
|
478
|
+
// Format active runs info
|
|
479
|
+
let activeRunsInfo = '无';
|
|
480
|
+
if (activeRuns.size > 0) {
|
|
481
|
+
const runInfos = [];
|
|
482
|
+
for (const [cwd, run] of activeRuns) {
|
|
483
|
+
runInfos.push(`- 📂 ${cwd}\n runId: ${run.runId.slice(0, 8)}...\n userId: ${run.userId}`);
|
|
484
|
+
}
|
|
485
|
+
activeRunsInfo = runInfos.join('\n');
|
|
486
|
+
}
|
|
487
|
+
const diagnosticCard = {
|
|
488
|
+
schema: '2.0',
|
|
489
|
+
config: { wide_screen_mode: true },
|
|
490
|
+
header: {
|
|
491
|
+
template: 'blue',
|
|
492
|
+
title: { tag: 'plain_text', content: '🔧 队列诊断报告' },
|
|
493
|
+
},
|
|
494
|
+
body: {
|
|
495
|
+
elements: [
|
|
496
|
+
{
|
|
497
|
+
tag: 'div',
|
|
498
|
+
text: { tag: 'lark_md', content: `**时间**: ${now}\n**进程 PID**: ${pid}` },
|
|
499
|
+
},
|
|
500
|
+
{ tag: 'hr' },
|
|
501
|
+
{
|
|
502
|
+
tag: 'div',
|
|
503
|
+
text: {
|
|
504
|
+
tag: 'lark_md',
|
|
505
|
+
content: `**消息信息**\n- 工作目录: \`${workspace}\`\n- 会话 ID: \`${sessionId ?? '(none)'}\`\n- 消息预览: ${task?.messagePreview ?? '(unknown)'}`,
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
{ tag: 'hr' },
|
|
509
|
+
{
|
|
510
|
+
tag: 'div',
|
|
511
|
+
text: {
|
|
512
|
+
tag: 'lark_md',
|
|
513
|
+
content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${queueInfo.tasksAhead}`,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
{ tag: 'hr' },
|
|
517
|
+
{
|
|
518
|
+
tag: 'div',
|
|
519
|
+
text: { tag: 'lark_md', content: `**活跃运行**\n${activeRunsInfo}` },
|
|
520
|
+
},
|
|
521
|
+
{ tag: 'hr' },
|
|
522
|
+
{
|
|
523
|
+
tag: 'div',
|
|
524
|
+
text: {
|
|
525
|
+
tag: 'lark_md',
|
|
526
|
+
content: `**可能原因**\n1. **当前有其他任务在运行** — 串行队列确保每次只执行一个任务\n2. **之前的任务卡住了** — 可点击"⚡ 立即执行"停止当前任务并执行这一条\n3. **Idle 超时触发** — 如果之前的任务长时间没有输出,watchdog 会自动停止它`,
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
await this.bridge.sendResult({ card: diagnosticCard }, ctx);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Handle queue.edit: show input field for editing the queued message.
|
|
536
|
+
*/
|
|
537
|
+
async handleQueueEdit(value, ctx) {
|
|
538
|
+
const workspace = value.workspace;
|
|
539
|
+
const messageId = value.messageId;
|
|
540
|
+
if (!workspace || !messageId) {
|
|
541
|
+
await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// Get current message preview
|
|
545
|
+
const task = this.bridge.getQueuedTask(workspace, messageId);
|
|
546
|
+
if (!task) {
|
|
547
|
+
await this.bridge.sendResult({ text: '⚠️ 该消息不在队列中(可能已开始执行)' }, ctx);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// Build edit card
|
|
551
|
+
// CardKit 2.0 input 自带 ✓ 提交图标,点击时输入值走 raw.action.input_value
|
|
552
|
+
// (SDK normalizer 丢弃,需 connector includeRawEvent: true + index.ts 从 raw 提取)。
|
|
553
|
+
// 用 column_set + input + behaviors,不用 form 容器(form 触发 300123 无 submit button /
|
|
554
|
+
// 200621 嵌套 column,submit-typed button 也被 CardKit 2.0 拒绝 HTTP 400)。
|
|
555
|
+
const editCard = {
|
|
556
|
+
schema: '2.0',
|
|
557
|
+
config: { wide_screen_mode: true },
|
|
558
|
+
header: {
|
|
559
|
+
template: 'blue',
|
|
560
|
+
title: { tag: 'plain_text', content: '✏️ 编辑排队消息' },
|
|
561
|
+
},
|
|
562
|
+
body: {
|
|
563
|
+
elements: [
|
|
564
|
+
{
|
|
565
|
+
tag: 'div',
|
|
566
|
+
text: { tag: 'lark_md', content: `**当前消息:**\n\`${task.messagePreview}\`` },
|
|
567
|
+
},
|
|
568
|
+
{ tag: 'hr' },
|
|
569
|
+
{
|
|
570
|
+
tag: 'div',
|
|
571
|
+
text: { tag: 'lark_md', content: '💡 输入新内容后点击右侧 ✓ 提交图标' },
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
tag: 'column_set',
|
|
575
|
+
columns: [
|
|
576
|
+
{
|
|
577
|
+
tag: 'column',
|
|
578
|
+
width: 'weighted',
|
|
579
|
+
weight: 3,
|
|
580
|
+
elements: [
|
|
581
|
+
{
|
|
582
|
+
tag: 'input',
|
|
583
|
+
name: 'newMessage',
|
|
584
|
+
placeholder: { tag: 'plain_text', content: '输入新的消息内容...' },
|
|
585
|
+
default_value: task.messagePreview,
|
|
586
|
+
behaviors: [
|
|
587
|
+
{ type: 'callback', value: { cmd: 'queue.input', workspace, messageId } },
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
// 原地更新原排队卡片,而不是发送新卡片
|
|
598
|
+
await this.bridge.updateCardInPlace(editCard, ctx);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Handle queue.input: update the queued message with new content.
|
|
602
|
+
* Returns a CardActionResponse carrying both a toast (immediate feedback,
|
|
603
|
+
* no separate message sent) and a `card` field so Feishu renders the
|
|
604
|
+
* updated queue card in place, closing the edit form. A toast-only response
|
|
605
|
+
* leaves the card stuck in the edit state (Feishu keeps the pre-click card
|
|
606
|
+
* when the callback response has no `card` field).
|
|
607
|
+
*/
|
|
608
|
+
async handleQueueInput(value, ctx) {
|
|
609
|
+
const workspace = value.workspace;
|
|
610
|
+
const messageId = value.messageId;
|
|
611
|
+
const newMessage = value.inputValue ?? value.formValue?.['newMessage'];
|
|
612
|
+
if (!workspace || !messageId) {
|
|
613
|
+
await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!newMessage) {
|
|
617
|
+
await this.bridge.sendResult({ text: '⚠️ 缺少新消息内容' }, ctx);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Update the message preview (and editedMessage so handleQueueImmediate
|
|
621
|
+
// re-enqueues the edited content instead of the stale original closure),
|
|
622
|
+
// and build the updated queue card. Returning the card in the callback
|
|
623
|
+
// response makes Feishu render it in place -- a toast-only response leaves
|
|
624
|
+
// the card stuck in the edit state (Feishu keeps the pre-click card when
|
|
625
|
+
// the callback response has no `card` field).
|
|
626
|
+
const card = await this.bridge.updateMessagePreview(workspace, messageId, newMessage);
|
|
627
|
+
if (!card) {
|
|
628
|
+
await this.bridge.sendResult({ text: '⚠️ 任务已不在队列中' }, ctx);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// 编辑即生效:自然轮到时(不经立即执行)也必须执行编辑后内容,而不是
|
|
632
|
+
// enqueue 时闭包冻结的旧指令。setTaskReplacement 原位替换该任务槽位的闭包,
|
|
633
|
+
// 保持队列位置;handleQueueImmediate 后续注册/刷新幂等覆盖。
|
|
634
|
+
// D3/Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
|
|
635
|
+
const inputTask = this.bridge.getQueuedTask(workspace, messageId);
|
|
636
|
+
this.bridge.setTaskReplacement(workspace, messageId, async () => {
|
|
637
|
+
await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask?.binding });
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
toast: { type: 'success', content: '消息已更新' },
|
|
641
|
+
card: { type: 'raw', data: card },
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Handle ls.switch: switch cwd to target directory after path validation.
|
|
646
|
+
*/
|
|
647
|
+
async handleLsSwitch(value, ctx) {
|
|
648
|
+
const targetPath = value.path;
|
|
649
|
+
if (!targetPath) {
|
|
650
|
+
await this.bridge.sendResult({ text: '卡片 payload 缺少 path' }, ctx);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
654
|
+
if (!cwd) {
|
|
655
|
+
await this.bridge.sendResult({ text: '请先使用 /cd <path> 设置工作目录' }, ctx);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
659
|
+
// 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
|
|
660
|
+
// 安全边界由 binder owner 认证兜底,见 docs/architecture/design.md §9.7 更新说明。
|
|
661
|
+
if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
|
|
662
|
+
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Canonicalize via realpath; if the target vanished between stat and
|
|
666
|
+
// realpath (TOCTOU), reject gracefully instead of throwing.
|
|
667
|
+
let canonical;
|
|
668
|
+
try {
|
|
669
|
+
canonical = fs.realpathSync(resolvedTarget);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// Set cwd + auto-resume + user notification (shared with /cd and /ws use)
|
|
676
|
+
const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
677
|
+
// Card channel: refresh ls card in-place (existing behavior preserved)
|
|
678
|
+
const card = this.cmdLs([canonical], ctx, 0);
|
|
679
|
+
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
680
|
+
// Message channel: send switch confirmation / auto-resume card to chat
|
|
681
|
+
await this.bridge.sendResult(notifyResult, ctx);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Handle ls.refresh: reload current directory.
|
|
685
|
+
*/
|
|
686
|
+
async handleLsRefresh(value, ctx) {
|
|
687
|
+
const targetPath = value.path;
|
|
688
|
+
const offset = value.offset ?? 0;
|
|
689
|
+
// TOCTOU guard (review P2-2): existsSync → statSync can race (target
|
|
690
|
+
// deleted in between), throwing into enqueueImmediate's catch which only
|
|
691
|
+
// logs — violating the "card button clicks must give visible feedback"
|
|
692
|
+
// red line. Treat a vanished/non-dir path as "use current cwd" instead of
|
|
693
|
+
// crashing the refresh.
|
|
694
|
+
let card;
|
|
695
|
+
try {
|
|
696
|
+
card =
|
|
697
|
+
targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
|
698
|
+
? this.cmdLs([targetPath], ctx, offset)
|
|
699
|
+
: this.cmdLs([], ctx, offset);
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
card = this.cmdLs([], ctx, offset);
|
|
703
|
+
}
|
|
704
|
+
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Handle ls.page: paginate to a specific offset.
|
|
708
|
+
*/
|
|
709
|
+
async handleLsPage(value, ctx) {
|
|
710
|
+
const targetPath = value.path;
|
|
711
|
+
const offset = value.offset ?? 0;
|
|
712
|
+
if (!targetPath) {
|
|
713
|
+
return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
|
|
714
|
+
}
|
|
715
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
716
|
+
// TOCTOU guard (review P2-2): existsSync → statSync can race; without the
|
|
717
|
+
// try/catch the throw lands in enqueueImmediate's silent .catch and the
|
|
718
|
+
// paginated card never updates (no visible feedback for the click).
|
|
719
|
+
let isDir;
|
|
720
|
+
try {
|
|
721
|
+
isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
isDir = false;
|
|
725
|
+
}
|
|
726
|
+
if (!isDir) {
|
|
727
|
+
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
728
|
+
}
|
|
729
|
+
const card = this.cmdLs([resolvedTarget], ctx, offset);
|
|
730
|
+
// 直接更新卡片,与 ls.browse/ls.refresh 行为一致
|
|
731
|
+
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
732
|
+
return { toast: { type: 'success', content: '' } };
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Handle resume.page: paginate the /resume session list in place.
|
|
736
|
+
* Mirrors handleLsPage — updates the same card, never sends a new one.
|
|
737
|
+
*/
|
|
738
|
+
async handleResumePage(value, ctx) {
|
|
739
|
+
const resumeAgent = value.agent ?? this.config.defaultAgent;
|
|
740
|
+
// Validate agent is a valid type (same as resume.use); fall back to default.
|
|
741
|
+
const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
|
|
742
|
+
const resolvedAgent = validAgents.includes(resumeAgent)
|
|
743
|
+
? resumeAgent
|
|
744
|
+
: this.config.defaultAgent;
|
|
745
|
+
const rawPageSize = Number(value.pageSize);
|
|
746
|
+
const pageSize = Number.isFinite(rawPageSize)
|
|
747
|
+
? Math.min(Math.max(Math.trunc(rawPageSize), 1), RESUME_PAGE_SIZE)
|
|
748
|
+
: RESUME_PAGE_SIZE;
|
|
749
|
+
// Normalize the callback offset to a finite non-negative integer, then
|
|
750
|
+
// align it to a page boundary so prev/next always step by pageSize.
|
|
751
|
+
// Non-numeric payloads (e.g. 'abc') must not produce NaN slices that look
|
|
752
|
+
// like an empty directory.
|
|
753
|
+
const rawOffset = Number(value.offset);
|
|
754
|
+
const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
|
|
755
|
+
const alignedOffset = Math.floor(offset / pageSize) * pageSize;
|
|
756
|
+
const entry = this.sessionStore.get(ctx.userId);
|
|
757
|
+
const cwd = entry?.cwd;
|
|
758
|
+
if (!cwd) {
|
|
759
|
+
return { toast: { type: 'error', content: '请先 /cd 设置工作目录' } };
|
|
760
|
+
}
|
|
761
|
+
// cmdResume clamps stale/out-of-range offsets and re-fetches internally.
|
|
762
|
+
const result = this.cmdResume([resolvedAgent, String(pageSize)], ctx, alignedOffset);
|
|
763
|
+
if (!result.card) {
|
|
764
|
+
// e.g. the agent has no sessions in this directory — surface an error
|
|
765
|
+
// toast instead of a contradictory success toast plus text reply.
|
|
766
|
+
// Agent name keeps the toast consistent with the text branch of cmdResume.
|
|
767
|
+
return {
|
|
768
|
+
toast: {
|
|
769
|
+
type: 'error',
|
|
770
|
+
content: `当前目录没有 ${agentDisplayName(resolvedAgent)} 的 session 记录`,
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
775
|
+
return { toast: { type: 'success', content: '' } };
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Handle ls.browse: browse directory without switching cwd.
|
|
779
|
+
*/
|
|
780
|
+
async handleLsBrowse(value, ctx) {
|
|
781
|
+
const targetPath = value.path;
|
|
782
|
+
if (!targetPath) {
|
|
783
|
+
await this.bridge.sendResult({ text: '卡片 payload 缺少 path' }, ctx);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
787
|
+
if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
|
|
788
|
+
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
// Always reset to page 0 when browsing to a new directory
|
|
792
|
+
const card = this.cmdLs([resolvedTarget], ctx, 0);
|
|
793
|
+
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Handle stop card action: interrupt current run.
|
|
797
|
+
*/
|
|
798
|
+
async handleCardStop(value, ctx) {
|
|
799
|
+
if (!value.runId || !ctx.userId || !ctx.chatId) {
|
|
800
|
+
await this.bridge.sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, ctx);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const stopped = await this.bridge.interruptCurrentRun({
|
|
804
|
+
userId: ctx.userId,
|
|
805
|
+
chatId: ctx.chatId,
|
|
806
|
+
runId: value.runId,
|
|
807
|
+
});
|
|
808
|
+
// Regression 2026-06-22: card stop button silently no-op'd when the
|
|
809
|
+
// run had already exited. Give the user a visible result.
|
|
810
|
+
if (!stopped) {
|
|
811
|
+
await this.bridge.sendResult({ text: '该任务已结束,无需终止' }, ctx);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Resolve an order's text at the enqueue boundary so order.exec can enter
|
|
816
|
+
* the serial queue as an equivalent user message (Plan A) — the same path a
|
|
817
|
+
* hand-typed message takes. index.ts calls this before bridge.enqueue and
|
|
818
|
+
* uses the returned `orderText` as the queue messagePreview / edit
|
|
819
|
+
* default_value, and `internalKey` as the queue dedup key.
|
|
820
|
+
*
|
|
821
|
+
* Why an internal key instead of the Feishu card messageId: one order card
|
|
822
|
+
* can be clicked many times, so the card messageId is 1:N with enqueue
|
|
823
|
+
* actions. Reusing it collides queuedTasks / queueCardMessages lookups.
|
|
824
|
+
* Each call therefore mints a unique key (the Feishu messageId still flows
|
|
825
|
+
* through ctx for replies/reactions — the two concerns are separate).
|
|
826
|
+
*
|
|
827
|
+
* `updateUsedAt` runs here, at resolve time, so the usage timestamp
|
|
828
|
+
* survives even an immediate crash before the queued task ever runs
|
|
829
|
+
* (preserves the H3 crash-safe property previously held by handleOrderExec,
|
|
830
|
+
* which recorded it right before forwardToClaude).
|
|
831
|
+
*
|
|
832
|
+
* Returns null when the order no longer exists; index.ts surfaces the error.
|
|
833
|
+
*/
|
|
834
|
+
resolveOrderExecForQueue(orderId) {
|
|
835
|
+
if (!this.orderStore.has(orderId))
|
|
836
|
+
return null;
|
|
837
|
+
const order = this.orderStore.get().find((o) => o.id === orderId);
|
|
838
|
+
if (!order)
|
|
839
|
+
return null;
|
|
840
|
+
this.orderStore.updateUsedAt(order.id);
|
|
841
|
+
return {
|
|
842
|
+
orderText: order.text,
|
|
843
|
+
internalKey: `order-${order.id}-${this.orderExecKeyCounter++}`,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Handle order.delete: remove the order and update card in place.
|
|
848
|
+
*/
|
|
849
|
+
async handleOrderDelete(value, ctx) {
|
|
850
|
+
const orderId = value.orderId;
|
|
851
|
+
if (!orderId) {
|
|
852
|
+
return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
|
|
853
|
+
}
|
|
854
|
+
if (!this.orderStore.has(orderId)) {
|
|
855
|
+
return { toast: { type: 'error', content: '指令不存在或已被删除' } };
|
|
856
|
+
}
|
|
857
|
+
// Delete the order
|
|
858
|
+
this.orderStore.remove(orderId);
|
|
859
|
+
// Refresh the order list and update card in place
|
|
860
|
+
const result = this.cmdOrder([], ctx);
|
|
861
|
+
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
862
|
+
return { toast: { type: 'success', content: '已删除指令' } };
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Handle ws.remove: remove the workspace alias and refresh the /ws list card
|
|
866
|
+
* in place (mirrors handleOrderDelete). Without this the stale alias would
|
|
867
|
+
* remain visible on the card the user just clicked.
|
|
868
|
+
*/
|
|
869
|
+
async handleWsRemove(value, ctx) {
|
|
870
|
+
const name = value.name;
|
|
871
|
+
if (!name) {
|
|
872
|
+
return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
|
|
873
|
+
}
|
|
874
|
+
// Execute the removal (consumes the write-side effect; the returned text
|
|
875
|
+
// is discarded — feedback flows through the toast + refreshed card).
|
|
876
|
+
this.cmdWs(['remove', name], ctx);
|
|
877
|
+
// Rebuild the /ws list card and update it in place.
|
|
878
|
+
const refreshed = this.cmdWs([], ctx);
|
|
879
|
+
await this.bridge.updateCardInPlace(refreshed.card, ctx);
|
|
880
|
+
return { toast: { type: 'success', content: `已删除 workspace "${name}"` } };
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* `ls.file` card button: send file to Feishu, rejecting if > 30MB.
|
|
884
|
+
*/
|
|
885
|
+
async cardLsFile(target, ctx) {
|
|
886
|
+
if (!target) {
|
|
887
|
+
await this.bridge.sendResult({ text: '卡片 payload 缺少 path' }, ctx);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const resolvedTarget = path.resolve(target);
|
|
891
|
+
if (!fs.existsSync(resolvedTarget)) {
|
|
892
|
+
await this.bridge.sendResult({ text: `文件不存在: ${resolvedTarget}` }, ctx);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
// Check it's a file, not directory
|
|
896
|
+
if (!fs.statSync(resolvedTarget).isFile()) {
|
|
897
|
+
await this.bridge.sendResult({ text: `不是文件: ${resolvedTarget}` }, ctx);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// Check file size (30MB, aligned with Feishu im/v1/files API)
|
|
901
|
+
const stat = fs.statSync(resolvedTarget);
|
|
902
|
+
if (stat.size > MAX_FILE_UPLOAD_SIZE) {
|
|
903
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
904
|
+
await this.bridge.sendResult({
|
|
905
|
+
text: `文件太大 (${sizeMB}MB),超过 ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB 限制不能发送`,
|
|
906
|
+
}, ctx);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
await this.bridge.sendFile(resolvedTarget, ctx);
|
|
910
|
+
}
|
|
911
|
+
// --- PendingConfig 辅助方法 ---
|
|
912
|
+
/** 确保 pendingConfig 已初始化(从当前 config 克隆) */
|
|
913
|
+
ensurePendingConfig() {
|
|
914
|
+
if (!this.pendingConfig) {
|
|
915
|
+
this.pendingConfig = structuredClone(this.config);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* 串行化 config.* 卡片回调(2026-07-04 修复 toggle 卡死 bug)。
|
|
920
|
+
*
|
|
921
|
+
* CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,不进 bridge 的
|
|
922
|
+
* 串行队列;多次快速点击 toggle 会并发读写 `pendingConfig` 并同时调
|
|
923
|
+
* `updateCardInPlace` → 两个 patch 请求乱序到达飞书,后到达的 patch(点击 1
|
|
924
|
+
* 的旧卡片)覆盖先到达的 patch(点击 2 的新卡片),用户看到 toggle 卡死。
|
|
925
|
+
*
|
|
926
|
+
* 此方法把所有 config.* 动作串成一个 Promise chain:每个动作等前一个动作
|
|
927
|
+
* 的 `updateCardInPlace` 完全 settle 后再读 `pendingConfig`。
|
|
928
|
+
*/
|
|
929
|
+
enqueueConfigAction(value, ctx) {
|
|
930
|
+
const run = async () => {
|
|
931
|
+
try {
|
|
932
|
+
return await this.dispatchConfigAction(value, ctx);
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
getLogger().error('[router] config action error:', err);
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
const next = this.configActionQueue.then(run);
|
|
939
|
+
// chain 永不 reject(错误已在 run 内吞掉),保证后续动作不被卡住
|
|
940
|
+
this.configActionQueue = next.then(() => undefined, () => undefined);
|
|
941
|
+
return next;
|
|
942
|
+
}
|
|
943
|
+
/** 实际的 config.* 分发逻辑(在串行队列内执行) */
|
|
944
|
+
async dispatchConfigAction(value, ctx) {
|
|
945
|
+
switch (value.cmd) {
|
|
946
|
+
case 'config.toggle': {
|
|
947
|
+
const key = value.key;
|
|
948
|
+
if (!key) {
|
|
949
|
+
await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
this.ensurePendingConfig();
|
|
953
|
+
const current = getConfigValue(this.pendingConfig, key);
|
|
954
|
+
// 布尔字段未设置时视为 false,而非报错(如布尔配置项尚未写入 config)
|
|
955
|
+
const boolVal = typeof current === 'boolean' ? current : false;
|
|
956
|
+
try {
|
|
957
|
+
// 只改暂存区,不写盘;原地更新卡片而非发送新卡(2026-07-04)
|
|
958
|
+
this.setNestedValue(this.pendingConfig, key, !boolVal);
|
|
959
|
+
await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
case 'config.set': {
|
|
967
|
+
// Set config value from select dropdown (option from CardKit 2.0)
|
|
968
|
+
const key = value.key;
|
|
969
|
+
const newValue = value.option;
|
|
970
|
+
if (!key || newValue === undefined) {
|
|
971
|
+
await this.bridge.sendResult({ text: '缺少参数' }, ctx);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
this.ensurePendingConfig();
|
|
976
|
+
// 使用 agent config builder 处理字段变更(处理依赖关系如 provider→model)
|
|
977
|
+
const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
|
|
978
|
+
const configBuilder = getConfigBuilder(defaultAgent);
|
|
979
|
+
const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
|
|
980
|
+
for (const patch of patches) {
|
|
981
|
+
this.setNestedValue(this.pendingConfig, patch.key, patch.value);
|
|
982
|
+
}
|
|
983
|
+
await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
987
|
+
}
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
case 'config.input': {
|
|
991
|
+
// Handle input field: value.key is the field key.
|
|
992
|
+
// 2026-07-04: CardKit 2.0 input 自带提交图标触发 callback,回传 input_value。
|
|
993
|
+
// Fall back to formValue[key]: the SDK currently reports input values
|
|
994
|
+
// via action.formValue for form-wrapped inputs.
|
|
995
|
+
const key = value.key;
|
|
996
|
+
if (!key) {
|
|
997
|
+
await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const newValue = value.inputValue ?? value.formValue?.[key];
|
|
1001
|
+
if (newValue === undefined) {
|
|
1002
|
+
await this.bridge.sendResult({ text: '缺少输入值' }, ctx);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
this.ensurePendingConfig();
|
|
1007
|
+
// 使用 agent config builder 处理字段变更
|
|
1008
|
+
const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
|
|
1009
|
+
const configBuilder = getConfigBuilder(defaultAgent);
|
|
1010
|
+
const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
|
|
1011
|
+
for (const patch of patches) {
|
|
1012
|
+
this.setNestedValue(this.pendingConfig, patch.key, patch.value);
|
|
1013
|
+
}
|
|
1014
|
+
await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
1018
|
+
}
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
case 'config.save': {
|
|
1022
|
+
// 批量保存:将 pendingConfig 一次性写入磁盘
|
|
1023
|
+
if (!this.pendingConfig) {
|
|
1024
|
+
await this.bridge.sendResult({ text: '没有待保存的修改' }, ctx);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const updates = this.diffConfig(this.config, this.pendingConfig);
|
|
1028
|
+
if (Object.keys(updates).length === 0) {
|
|
1029
|
+
this.pendingConfig = null;
|
|
1030
|
+
await this.bridge.sendResult({ text: '没有变更需要保存' }, ctx);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
const oldDefaultAgent = this.config.defaultAgent;
|
|
1035
|
+
this.config = setConfigValues(this.configPath, this.config, updates);
|
|
1036
|
+
// P1-6:运行时传播(idleTimeout / clearRunners / syncAgentChoices /
|
|
1037
|
+
// defaultAgent 切换的 session 处理)抽到共享方法,与文本直写路径共用。
|
|
1038
|
+
const switchNotice = this.propagateConfigSave(oldDefaultAgent, updates, ctx);
|
|
1039
|
+
this.pendingConfig = null;
|
|
1040
|
+
// 原地更新卡片:保存后卡片刷新为已保存状态(2026-07-04)
|
|
1041
|
+
// 2026-08-03: 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
|
|
1042
|
+
// 后续切换消息照常发送;真正写盘/传播阶段的失败才走外层 catch 报「保存失败」。
|
|
1043
|
+
try {
|
|
1044
|
+
await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
|
|
1045
|
+
}
|
|
1046
|
+
catch (refreshErr) {
|
|
1047
|
+
getLogger().warn(`[router] config.save 卡片刷新失败(保存已成功): ${refreshErr.message}`);
|
|
1048
|
+
}
|
|
1049
|
+
// 2026-08-03: agent 切换时发送持久化消息通知用户(toast 不持久化,
|
|
1050
|
+
// 聊天记录不可回溯);发送失败(resolve false,不 throw)时兜底回退
|
|
1051
|
+
// toast 即时反馈,复用同一个切换文案。
|
|
1052
|
+
if (switchNotice) {
|
|
1053
|
+
const sent = await this.bridge.sendResult({ text: switchNotice }, ctx);
|
|
1054
|
+
if (!sent) {
|
|
1055
|
+
return {
|
|
1056
|
+
toast: { type: 'info', content: switchNotice },
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
await this.bridge.sendResult({ text: `保存失败: ${err.message}` }, ctx);
|
|
1063
|
+
}
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
default:
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/** 设置嵌套属性值(仅修改内存对象,不写盘) */
|
|
1071
|
+
setNestedValue(target, key, value) {
|
|
1072
|
+
// Path mapping 共用 config 模块的 mapAgentKey(G11 Inconsistency 修复):
|
|
1073
|
+
// pi.xxx/codex.xxx/opencode.xxx → agents.xxx,claude.xxx 保持顶层
|
|
1074
|
+
const mappedKey = mapAgentKey(key);
|
|
1075
|
+
const parts = mappedKey.split('.');
|
|
1076
|
+
let current = target;
|
|
1077
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1078
|
+
// §P1-4:卡片 config.set/config.input 的 key 来自卡片 action,
|
|
1079
|
+
// 必须与 config 模块的 setNestedValue/deleteNestedValue/getConfigValue 同等
|
|
1080
|
+
// 守卫 __proto__/prototype/constructor 段——否则伪造 key 经
|
|
1081
|
+
// `current['__proto__']` 污染 Object.prototype(文本直写路径已守卫,
|
|
1082
|
+
// 卡片路径是同一威胁模型的另一入口)。
|
|
1083
|
+
assertSafeKeyPart(parts[i]);
|
|
1084
|
+
const next = current[parts[i]];
|
|
1085
|
+
if (next == null || typeof next !== 'object') {
|
|
1086
|
+
current[parts[i]] = {};
|
|
1087
|
+
}
|
|
1088
|
+
current = current[parts[i]];
|
|
1089
|
+
}
|
|
1090
|
+
assertSafeKeyPart(parts[parts.length - 1]);
|
|
1091
|
+
if (value === undefined) {
|
|
1092
|
+
// value=undefined 表示"删除键"(如清空 reasoningEffort),
|
|
1093
|
+
// 不能写成 undefined 值——diffConfig 会把 undefined 转成字面量 "undefined"
|
|
1094
|
+
// 写入 config.yaml 并透传给 codex(ReasoningEffort::Custom("undefined"))。
|
|
1095
|
+
delete current[parts[parts.length - 1]];
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
current[parts[parts.length - 1]] = value;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/** 对比原始 config 与 pendingConfig,返回变化的 key→string 映射 */
|
|
1102
|
+
diffConfig(original, pending) {
|
|
1103
|
+
const updates = {};
|
|
1104
|
+
this.collectDiff('', original, pending, updates);
|
|
1105
|
+
return updates;
|
|
1106
|
+
}
|
|
1107
|
+
/** 递归收集差异 */
|
|
1108
|
+
collectDiff(prefix, original, pending, result) {
|
|
1109
|
+
if (original === pending)
|
|
1110
|
+
return;
|
|
1111
|
+
// 如果 pending 是对象(非 null)但 original 不是对象,
|
|
1112
|
+
// 把 original 当作空对象递归进入 pending 内部,
|
|
1113
|
+
// 避免把整个对象转成 "[object Object]" 字符串
|
|
1114
|
+
if (typeof pending === 'object' &&
|
|
1115
|
+
pending !== null &&
|
|
1116
|
+
(typeof original !== 'object' || original === null)) {
|
|
1117
|
+
const pendObj = pending;
|
|
1118
|
+
for (const k of Object.keys(pendObj)) {
|
|
1119
|
+
const newKey = prefix ? `${prefix}.${k}` : k;
|
|
1120
|
+
this.collectDiff(newKey, undefined, pendObj[k], result);
|
|
1121
|
+
}
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (typeof original !== 'object' ||
|
|
1125
|
+
typeof pending !== 'object' ||
|
|
1126
|
+
original === null ||
|
|
1127
|
+
pending === null) {
|
|
1128
|
+
// 叶子节点,记录差异
|
|
1129
|
+
const key = prefix || 'root';
|
|
1130
|
+
// pending 为 undefined 表示键被删除,必须保留 undefined 语义,
|
|
1131
|
+
// 不能 String(pending) 成 "undefined"
|
|
1132
|
+
result[key] = pending === undefined ? undefined : String(pending);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// 两者都是对象,递归比较
|
|
1136
|
+
const origObj = original;
|
|
1137
|
+
const pendObj = pending;
|
|
1138
|
+
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(pendObj)]);
|
|
1139
|
+
for (const k of allKeys) {
|
|
1140
|
+
const newKey = prefix ? `${prefix}.${k}` : k;
|
|
1141
|
+
this.collectDiff(newKey, origObj[k], pendObj[k], result);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* config 写盘后的运行时传播(P1-6:自 config.save 卡片路径抽出,卡片保存与
|
|
1146
|
+
* `/config <key> <value>` 文本直写两条路径共用)。
|
|
1147
|
+
*
|
|
1148
|
+
* 1. bridge 运行时值同步:setConfig + idle watchdog timeout;
|
|
1149
|
+
* 2. agent 配置或 defaultAgent 变更 → clearRunners(否则 runner 缓存继续用旧
|
|
1150
|
+
* 配置值,/status 与真实 run 自相矛盾);
|
|
1151
|
+
* 3. defaultAgent 切换 → 旧 agent session 存 previousSessions、新 agent 恢复
|
|
1152
|
+
* 或清空(等效 /new);
|
|
1153
|
+
* 4. 当前 agent 配置变更 → syncAgentChoices 并原子写盘(P1-7:禁裸同步写盘,
|
|
1154
|
+
* 写一半崩溃会截断 config.yaml 导致 bridge 起不来)。
|
|
1155
|
+
*
|
|
1156
|
+
* @param updates 以 config 路径形式给出的变更 key(卡片路径来自 diffConfig;
|
|
1157
|
+
* 直写路径传 mapAgentKey(key),保证 pi./codex./opencode./kimi. 命中 agents.*)。
|
|
1158
|
+
* @returns defaultAgent 切换时的通知文案(未切换返回 undefined)
|
|
1159
|
+
*/
|
|
1160
|
+
propagateConfigSave(oldDefaultAgent, updates, ctx) {
|
|
1161
|
+
this.bridge.setConfig(this.config);
|
|
1162
|
+
// idle.watchdogMinutes 改动需同步到 bridge 的 idleTimeoutMs
|
|
1163
|
+
this.idleTimeoutMs = this.config.idle.watchdogMinutes * 60_000;
|
|
1164
|
+
this.bridge.setIdleTimeout(this.idleTimeoutMs);
|
|
1165
|
+
// 2026-07-13: 检测是否有任何 agent 配置变更或 defaultAgent 切换
|
|
1166
|
+
const hasDefaultAgentChange = oldDefaultAgent !== this.config.defaultAgent;
|
|
1167
|
+
const hasAgentConfigChange = Object.keys(updates).some((k) => k.startsWith('agents.') ||
|
|
1168
|
+
k.startsWith('claude.') ||
|
|
1169
|
+
k.startsWith('pi.') ||
|
|
1170
|
+
k.startsWith('codex.') ||
|
|
1171
|
+
k.startsWith('opencode.') ||
|
|
1172
|
+
k.startsWith('kimi.') ||
|
|
1173
|
+
k === 'defaultAgent' ||
|
|
1174
|
+
k === 'claude' ||
|
|
1175
|
+
k === 'pi' ||
|
|
1176
|
+
k === 'codex' ||
|
|
1177
|
+
k === 'opencode' ||
|
|
1178
|
+
k === 'kimi');
|
|
1179
|
+
// 当 defaultAgent 改变时,清除旧 agent 的 sessionId,
|
|
1180
|
+
// 同时清除新 agent 的 sessionId(等效于 /new 命令),
|
|
1181
|
+
// 并支持 session 恢复 - 切换回来时如果条件满足则恢复之前的 session。
|
|
1182
|
+
let switchNotice;
|
|
1183
|
+
if (hasDefaultAgentChange) {
|
|
1184
|
+
const newAgent = this.config.defaultAgent;
|
|
1185
|
+
// Step 0: 在清空 old 之前计算「用户活动」基线差(arrival 基线):
|
|
1186
|
+
// arrivalSessions[old] 是用户最近一次经 config.save 切换「到达」该 agent
|
|
1187
|
+
// 时被赋予的 session id(清空到达 = '')。当前 session 与到达基线不一致
|
|
1188
|
+
// ⇒ 用户在 oldAgent 上产生过用户活动(发消息 /resume /new /cd 等,均
|
|
1189
|
+
// 不更新 arrival),离开时恢复必须被阻断。
|
|
1190
|
+
const oldSessionId = this.sessionStore.getSessionId(ctx.userId, oldDefaultAgent);
|
|
1191
|
+
const oldArrivalSessionId = this.sessionStore.getArrivalSessionId(ctx.userId, oldDefaultAgent);
|
|
1192
|
+
const userChangedOld = (oldSessionId ?? '') !== (oldArrivalSessionId ?? '');
|
|
1193
|
+
// Step 1: 保存旧 agent 的 session 到 previousSessions(如果有)
|
|
1194
|
+
if (oldSessionId) {
|
|
1195
|
+
this.sessionStore.setPreviousSessionId(ctx.userId, oldDefaultAgent, oldSessionId);
|
|
1196
|
+
}
|
|
1197
|
+
// Step 2: 清除旧 agent 的 session(保存后清除,为新会话腾出空间)
|
|
1198
|
+
this.sessionStore.clearSessionId(ctx.userId, oldDefaultAgent);
|
|
1199
|
+
// Step 2.5: 显式选择判定——sessions[new] 非空且不等于到达基线 ⇒ 用户经 /resume
|
|
1200
|
+
// 显式改选了 new 的 session(/resume 不更新 arrival),切换时必须让选择存活。
|
|
1201
|
+
// 优先级:显式选择 > 停车恢复 > 清空;newSessionId 为空(防御)不触发本分支。
|
|
1202
|
+
const newSessionId = this.sessionStore.getSessionId(ctx.userId, newAgent);
|
|
1203
|
+
const newArrivalSessionId = this.sessionStore.getArrivalSessionId(ctx.userId, newAgent);
|
|
1204
|
+
const userSelectedNew = !!newSessionId && (newSessionId ?? '') !== (newArrivalSessionId ?? '');
|
|
1205
|
+
// Step 3: 恢复判定——previousSessions[newAgent] 存在 且 用户在 oldAgent 上
|
|
1206
|
+
// 没有用户活动(session 未变于到达基线)。判断方法:arrival 基线差
|
|
1207
|
+
// userChangedOld 为 false 时,oldAgent 的 session 只可能是 config.save
|
|
1208
|
+
// 自动恢复/清空得到的,不构成用户活动,恢复不被阻断。
|
|
1209
|
+
const previousSessionId = this.sessionStore.getPreviousSessionId(ctx.userId, newAgent);
|
|
1210
|
+
const canRestore = !!previousSessionId && !userChangedOld;
|
|
1211
|
+
if (userSelectedNew) {
|
|
1212
|
+
// 显式选择优先于停车恢复与用户活动阻断:保留 sessions[new](不动),更新
|
|
1213
|
+
// arrival 基线为所选 session;prev[new] 停车位原样保留(下次离开时会被
|
|
1214
|
+
// sessions[new] 覆盖停车)。
|
|
1215
|
+
this.sessionStore.setArrivalSessionId(ctx.userId, newAgent, newSessionId);
|
|
1216
|
+
const newAgentName = agentDisplayName(newAgent);
|
|
1217
|
+
switchNotice = `已切换到 ${newAgentName},已使用所选 session,sessionId: ${newSessionId}`;
|
|
1218
|
+
}
|
|
1219
|
+
else if (canRestore) {
|
|
1220
|
+
// 可以恢复之前的 session
|
|
1221
|
+
this.sessionStore.setSessionId(ctx.userId, newAgent, previousSessionId);
|
|
1222
|
+
// 恢复分支消费 previousSessions,并记录新的到达基线(自动恢复的 session
|
|
1223
|
+
// 不构成用户活动,后续离开时 userChangedOld 仍为 false)
|
|
1224
|
+
this.sessionStore.clearPreviousSessionId(ctx.userId, newAgent);
|
|
1225
|
+
this.sessionStore.setArrivalSessionId(ctx.userId, newAgent, previousSessionId);
|
|
1226
|
+
const newAgentName = agentDisplayName(newAgent);
|
|
1227
|
+
switchNotice = `已切换到 ${newAgentName},将继续之前的 session,sessionId: ${previousSessionId}`;
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
// Step 4: 清除新 agent 的 session(等效于 /new)
|
|
1231
|
+
this.sessionStore.clearSessionId(ctx.userId, newAgent, { clearSessionCwd: true });
|
|
1232
|
+
// 清空分支不清除 previousSessions[newAgent](停车语义:被拒绝的恢复
|
|
1233
|
+
// 机会保留,未来无用户活动的返回仍可恢复);记录清空到达基线
|
|
1234
|
+
this.sessionStore.setArrivalSessionId(ctx.userId, newAgent, '');
|
|
1235
|
+
const newAgentName = agentDisplayName(newAgent);
|
|
1236
|
+
switchNotice = `已切换到 ${newAgentName},session 已清空,下次消息将启动新对话`;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
// 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
|
|
1240
|
+
// 检测是否有当前 agent 的配置变更
|
|
1241
|
+
const currentAgent = this.config.defaultAgent;
|
|
1242
|
+
const currentAgentConfigKey = `agents.${currentAgent}`;
|
|
1243
|
+
const hasCurrentAgentUpdate = Object.keys(updates).some((k) => k === currentAgentConfigKey || k.startsWith(`${currentAgentConfigKey}.`));
|
|
1244
|
+
if (hasCurrentAgentUpdate) {
|
|
1245
|
+
this.config = syncAgentChoices(this.config, currentAgent);
|
|
1246
|
+
// P1-7:agentChoices 同步写盘必须走原子写(tmp+rename),禁裸同步写盘
|
|
1247
|
+
atomicWrite(this.configPath, YAML.stringify(this.config));
|
|
1248
|
+
this.bridge.setConfig(this.config);
|
|
1249
|
+
}
|
|
1250
|
+
// 2026-07-13: 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
|
|
1251
|
+
// 否则 runner 仍使用旧配置值
|
|
1252
|
+
if (hasDefaultAgentChange || hasAgentConfigChange) {
|
|
1253
|
+
this.bridge.clearRunners();
|
|
1254
|
+
}
|
|
1255
|
+
return switchNotice;
|
|
1256
|
+
}
|
|
1257
|
+
async executeCommand(message, ctx) {
|
|
1258
|
+
const parts = message.slice(1).split(/\s+/);
|
|
1259
|
+
const cmd = parts[0]?.toLowerCase();
|
|
1260
|
+
const args = parts.slice(1);
|
|
1261
|
+
switch (cmd) {
|
|
1262
|
+
case 'help':
|
|
1263
|
+
case 'h':
|
|
1264
|
+
return this.cmdHelp();
|
|
1265
|
+
case 'status':
|
|
1266
|
+
case 's':
|
|
1267
|
+
return this.cmdStatus(ctx);
|
|
1268
|
+
case 'ps':
|
|
1269
|
+
return this.cmdPs(ctx);
|
|
1270
|
+
case 'stop':
|
|
1271
|
+
case 't':
|
|
1272
|
+
return await this.cmdStop(ctx);
|
|
1273
|
+
case 'exit':
|
|
1274
|
+
case 'quit':
|
|
1275
|
+
case 'e':
|
|
1276
|
+
return await this.cmdExit();
|
|
1277
|
+
case 'restart':
|
|
1278
|
+
return this.cmdRestart();
|
|
1279
|
+
case 'new':
|
|
1280
|
+
return this.cmdNew(ctx);
|
|
1281
|
+
case 'cd':
|
|
1282
|
+
return this.cmdCd(args, ctx);
|
|
1283
|
+
case 'ls':
|
|
1284
|
+
return this.cmdLs(args, ctx);
|
|
1285
|
+
case 'ws':
|
|
1286
|
+
return this.cmdWs(args, ctx);
|
|
1287
|
+
case 'resume':
|
|
1288
|
+
case 'r':
|
|
1289
|
+
return this.cmdResume(args, ctx);
|
|
1290
|
+
case 'active':
|
|
1291
|
+
return this.cmdActive(args, ctx);
|
|
1292
|
+
case 'reconnect':
|
|
1293
|
+
return await this.cmdReconnect();
|
|
1294
|
+
case 'config':
|
|
1295
|
+
case 'c':
|
|
1296
|
+
return this.cmdConfig(args, ctx);
|
|
1297
|
+
case 'order':
|
|
1298
|
+
case 'o':
|
|
1299
|
+
return this.cmdOrder(args, ctx);
|
|
1300
|
+
default:
|
|
1301
|
+
return { text: `未知命令 /${cmd},输入 /help 查看可用命令` };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
// --- Command implementations ---
|
|
1305
|
+
cmdHelp() {
|
|
1306
|
+
// 按钮组(可直接点击触发;按钮 label 只保留子命令,不含参数)
|
|
1307
|
+
// 2026-07-04: /ws 子命令 (save|use|remove) 移到右侧文本说明,按钮只显示 /ws
|
|
1308
|
+
// 2026-07-04: 按钮 label 按长度升序排列(短在上,长在下),视觉更整齐
|
|
1309
|
+
const buttonCommands = [
|
|
1310
|
+
{ cmd: 'help', label: '/help /h', desc: '显示此帮助' },
|
|
1311
|
+
{ cmd: 'ps', label: '/ps', desc: '查看是否有 agent 进程在跑' },
|
|
1312
|
+
{ cmd: 'new', label: '/new', desc: '清空当前 session(保留 cwd)' },
|
|
1313
|
+
{ cmd: 'stop', label: '/stop /t', desc: '终止当前 Agent 进程' },
|
|
1314
|
+
{ cmd: 'ws', label: '/ws', desc: 'workspace 管理(save|use|remove)' },
|
|
1315
|
+
{ cmd: 'config', label: '/config /c', desc: '查看和修改配置(卡片交互)' },
|
|
1316
|
+
{ cmd: 'active', label: '/active', desc: '查看所有正在进行中的 session' },
|
|
1317
|
+
{ cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
|
|
1318
|
+
{ cmd: 'restart', label: '/restart', desc: '重启 bridge(新进程,config 不变)' },
|
|
1319
|
+
{ cmd: 'status', label: '/status /s', desc: '显示当前状态' },
|
|
1320
|
+
{ cmd: 'reconnect', label: '/reconnect', desc: '重连飞书' },
|
|
1321
|
+
].sort((a, b) => a.label.length - b.label.length || a.cmd.localeCompare(b.cmd));
|
|
1322
|
+
// 文本组(需带参数,不宜用按钮;以纯文本行展示)
|
|
1323
|
+
const textCommands = [
|
|
1324
|
+
{ cmd: 'cd', label: '/cd <path>', desc: '切换工作目录' },
|
|
1325
|
+
{ cmd: 'ls', label: '/ls [dir]', desc: '列出当前目录,可指定子目录' },
|
|
1326
|
+
{ cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
|
|
1327
|
+
{ cmd: 'order', label: '/order /o save|list', desc: '保存或列出常用指令(/order 默认列出)' },
|
|
1328
|
+
];
|
|
1329
|
+
const bodyElements = [];
|
|
1330
|
+
// 按钮组 — CardKit 2.0: column_set + column + button with behaviors
|
|
1331
|
+
// 2026-07-04 对齐修复(方案 A):两列都用 width:'weighted' + 固定 weight。
|
|
1332
|
+
// 根因:每行独立 column_set + width:'auto' → 列宽随按钮/文本内容变化 →
|
|
1333
|
+
// 跨行不对齐(按钮列右边界参差)。改用 weighted 后,每行 column_set
|
|
1334
|
+
// 总宽相同(同级 body.elements),weight 比例一致,列宽跨行恒等。
|
|
1335
|
+
// 2026-07-05 手机排版修复:weight 1:3 → 2:3。1:3 时按钮列只占 25%,
|
|
1336
|
+
// 手机窄屏(~330px 可用)下约 82px,容不下 `/reconnect`(10 字符 ≈ 100px),
|
|
1337
|
+
// 按钮文字被截断成 "..."。改 2:3 后按钮列占 40% ≈ 132px,足够显示
|
|
1338
|
+
// 最长的 /reconnect,文本列仍占 60% 足以放下描述。
|
|
1339
|
+
for (const c of buttonCommands) {
|
|
1340
|
+
bodyElements.push({
|
|
1341
|
+
tag: 'column_set',
|
|
1342
|
+
columns: [
|
|
1343
|
+
{
|
|
1344
|
+
tag: 'column',
|
|
1345
|
+
width: 'weighted',
|
|
1346
|
+
weight: 2,
|
|
1347
|
+
elements: [
|
|
1348
|
+
{
|
|
1349
|
+
tag: 'button',
|
|
1350
|
+
text: { tag: 'plain_text', content: c.label },
|
|
1351
|
+
type: 'default',
|
|
1352
|
+
size: 'small',
|
|
1353
|
+
behaviors: [{ type: 'callback', value: { cmd: `help.${c.cmd}` } }],
|
|
1354
|
+
},
|
|
1355
|
+
],
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
tag: 'column',
|
|
1359
|
+
width: 'weighted',
|
|
1360
|
+
weight: 3,
|
|
1361
|
+
vertical_align: 'center',
|
|
1362
|
+
elements: [
|
|
1363
|
+
{
|
|
1364
|
+
tag: 'div',
|
|
1365
|
+
text: { tag: 'lark_md', content: c.desc },
|
|
1366
|
+
},
|
|
1367
|
+
],
|
|
1368
|
+
},
|
|
1369
|
+
],
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
// 分隔:按钮组与文本组之间
|
|
1373
|
+
bodyElements.push({ tag: 'hr' });
|
|
1374
|
+
// 文本组 — lark_md 文本行
|
|
1375
|
+
for (const c of textCommands) {
|
|
1376
|
+
bodyElements.push({
|
|
1377
|
+
tag: 'div',
|
|
1378
|
+
text: { tag: 'lark_md', content: `\`${c.label}\` — ${c.desc}` },
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
// 快捷命令
|
|
1382
|
+
bodyElements.push({ tag: 'hr' });
|
|
1383
|
+
bodyElements.push({
|
|
1384
|
+
tag: 'div',
|
|
1385
|
+
text: {
|
|
1386
|
+
tag: 'lark_md',
|
|
1387
|
+
content: '**快捷命令**\n`!<bash-command>` — 执行 bash 命令并流式输出到卡片',
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
// Agent 说明
|
|
1391
|
+
bodyElements.push({ tag: 'hr' });
|
|
1392
|
+
bodyElements.push({
|
|
1393
|
+
tag: 'div',
|
|
1394
|
+
text: {
|
|
1395
|
+
tag: 'lark_md',
|
|
1396
|
+
content: '**Agent**\n通过 `/config` → 默认 Agent 切换:Claude / Codex / Opencode / Pi / Kimi',
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
return {
|
|
1400
|
+
card: {
|
|
1401
|
+
schema: '2.0',
|
|
1402
|
+
config: { wide_screen_mode: true },
|
|
1403
|
+
header: { title: { tag: 'plain_text', content: '📖 可用命令' } },
|
|
1404
|
+
body: { elements: bodyElements },
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
cmdStatus(ctx) {
|
|
1409
|
+
const entry = this.sessionStore.get(ctx.userId);
|
|
1410
|
+
const cwd = entry?.cwd ?? '(未设置)';
|
|
1411
|
+
const sessionId = entry?.sessions?.get(this.config.defaultAgent) ?? '(无)';
|
|
1412
|
+
// 查 bridge.activeRuns 而不是默认 runner.isRunning —— 因为 per-workspace
|
|
1413
|
+
// runner 启动的 run 不会反映到默认 runner 状态。
|
|
1414
|
+
const running = cwd !== '(未设置)' && this.bridge.isBusyFor(cwd);
|
|
1415
|
+
const logger = getLogger();
|
|
1416
|
+
const pid = process.pid;
|
|
1417
|
+
const configDir = getConfigDir();
|
|
1418
|
+
const logFile = typeof logger.getCurrentLogFile === 'function' ? logger.getCurrentLogFile() : '';
|
|
1419
|
+
// 构建 agent 状态信息
|
|
1420
|
+
const agentLines = [];
|
|
1421
|
+
if (cwd !== '(未设置)') {
|
|
1422
|
+
const runner = this.bridge.getCurrentRunner(cwd);
|
|
1423
|
+
const info = runner.getStatusInfo();
|
|
1424
|
+
// 只保留第一个 agent 和 model
|
|
1425
|
+
agentLines.push(`agent: \`${info.kind}\``);
|
|
1426
|
+
agentLines.push(`model: \`${info.model}\``);
|
|
1427
|
+
if (info.provider) {
|
|
1428
|
+
agentLines.push(`provider: \`${info.provider}\``);
|
|
1429
|
+
}
|
|
1430
|
+
if (info.reasoning) {
|
|
1431
|
+
agentLines.push(`reasoning: \`${info.reasoning}\``);
|
|
1432
|
+
}
|
|
1433
|
+
if (info.extras) {
|
|
1434
|
+
for (const [key, value] of Object.entries(info.extras)) {
|
|
1435
|
+
agentLines.push(`${key}: \`${value}\``);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
// 如果上面没有成功获取 agent 信息(cwd 未设置或其他原因),从配置读取
|
|
1440
|
+
if (agentLines.length === 0) {
|
|
1441
|
+
const defaultAgent = this.config.defaultAgent;
|
|
1442
|
+
let model = 'N/A';
|
|
1443
|
+
if (defaultAgent === 'claude') {
|
|
1444
|
+
model = this.config.claude.model;
|
|
1445
|
+
}
|
|
1446
|
+
else if (this.config.agents?.[defaultAgent]) {
|
|
1447
|
+
const agentConfig = this.config.agents[defaultAgent];
|
|
1448
|
+
model = agentConfig.model ?? 'N/A';
|
|
1449
|
+
}
|
|
1450
|
+
agentLines.push(`agent: \`${defaultAgent}\``);
|
|
1451
|
+
agentLines.push(`model: \`${model}\``);
|
|
1452
|
+
}
|
|
1453
|
+
const sessionCwd = this.sessionStore.getSessionCwd(ctx.userId, this.config.defaultAgent);
|
|
1454
|
+
const sessionCwdLine = sessionCwd && sessionCwd !== cwd ? `- 会话目录: \`${sessionCwd}\`(会话实际目录)\n` : '';
|
|
1455
|
+
return {
|
|
1456
|
+
markdown: `**当前状态:**
|
|
1457
|
+
- cwd: \`${cwd}\`
|
|
1458
|
+
- session_id: \`${sessionId}\`
|
|
1459
|
+
${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
1460
|
+
- 进程: ${running ? '运行中' : '空闲'}
|
|
1461
|
+
- 进程号: ${pid}
|
|
1462
|
+
- 配置目录: ${configDir}
|
|
1463
|
+
- 日志文件: ${logFile}`,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
cmdPs(ctx) {
|
|
1467
|
+
const entry = this.sessionStore.get(ctx.userId);
|
|
1468
|
+
const cwd = entry?.cwd;
|
|
1469
|
+
if (!cwd)
|
|
1470
|
+
return { text: '请先 /cd 设置工作目录' };
|
|
1471
|
+
const agentName = agentDisplayName(this.config.defaultAgent);
|
|
1472
|
+
return {
|
|
1473
|
+
text: this.bridge.isBusyFor(cwd) ? `有 ${agentName} 进程在运行` : '当前无进程在跑',
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
async cmdStop(ctx) {
|
|
1477
|
+
const stopped = await this.bridge.interruptCurrentRun({
|
|
1478
|
+
userId: ctx.userId,
|
|
1479
|
+
chatId: ctx.chatId,
|
|
1480
|
+
});
|
|
1481
|
+
return stopped ? null : { text: '当前没有运行中的进程' };
|
|
1482
|
+
}
|
|
1483
|
+
async cmdExit() {
|
|
1484
|
+
// Signal handle() to invoke exitHandler after the reply is sent.
|
|
1485
|
+
this.pendingExit = true;
|
|
1486
|
+
return { text: 'bridge 正在退出...' };
|
|
1487
|
+
}
|
|
1488
|
+
cmdRestart() {
|
|
1489
|
+
if (!this.restartSpawner) {
|
|
1490
|
+
return { text: '当前环境不支持 /restart' };
|
|
1491
|
+
}
|
|
1492
|
+
try {
|
|
1493
|
+
// 先 spawn detached 继任者(持锁期间完成,子进程经 env 等本进程退出后
|
|
1494
|
+
// 再走正常锁 acquire),成功后才借 pendingExit 走 /exit 同款干净退出
|
|
1495
|
+
// 链路——spawn 失败则旧进程保持存活,不会两头落空。
|
|
1496
|
+
const pid = this.restartSpawner();
|
|
1497
|
+
this.pendingExit = true;
|
|
1498
|
+
return { text: `♻️ bridge 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
|
|
1499
|
+
}
|
|
1500
|
+
catch (err) {
|
|
1501
|
+
getLogger().error('[router] restart spawn failed:', err);
|
|
1502
|
+
return { text: `重启失败:${err.message},旧进程仍在运行` };
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
cmdNew(ctx) {
|
|
1506
|
+
// 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
|
|
1507
|
+
// 设置工作目录",但用户的 workspace 还在。2026-06-21 复盘:用户
|
|
1508
|
+
// /new → /resume 后被误导以为需要重新 /cd。
|
|
1509
|
+
const agentName = agentDisplayName(this.config.defaultAgent);
|
|
1510
|
+
this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
|
|
1511
|
+
clearSessionCwd: true,
|
|
1512
|
+
});
|
|
1513
|
+
return { text: `已清空 ${agentName} session,下次消息将启动新对话` };
|
|
1514
|
+
}
|
|
1515
|
+
cmdCd(args, ctx) {
|
|
1516
|
+
const target = args[0];
|
|
1517
|
+
if (!target) {
|
|
1518
|
+
const cwd = this.sessionStore.getCwd(ctx.userId) ?? '(未设置)';
|
|
1519
|
+
return { text: `当前目录: ${cwd}` };
|
|
1520
|
+
}
|
|
1521
|
+
// Resolve path (support ~, absolute, and relative paths)
|
|
1522
|
+
const currentCwd = this.sessionStore.getCwd(ctx.userId) ?? process.cwd();
|
|
1523
|
+
const expanded = target.startsWith('~') ? path.join(os.homedir(), target.slice(1)) : target;
|
|
1524
|
+
const resolved = path.resolve(currentCwd, expanded);
|
|
1525
|
+
if (!fs.existsSync(resolved)) {
|
|
1526
|
+
return { text: `路径不存在: ${resolved}` };
|
|
1527
|
+
}
|
|
1528
|
+
const stat = fs.statSync(resolved);
|
|
1529
|
+
if (!stat.isDirectory()) {
|
|
1530
|
+
return { text: `不是目录: ${resolved}` };
|
|
1531
|
+
}
|
|
1532
|
+
// Canonicalize via realpath so it matches the symlink-resolved cwd that
|
|
1533
|
+
// Claude writes into JSONL (e.g. `/tmp` → `/private/tmp` on macOS).
|
|
1534
|
+
// Without this, a hand-typed unresolved path never matches any JSONL's
|
|
1535
|
+
// cwd field and the user's `/cd /tmp/foo` never finds their sessions.
|
|
1536
|
+
// TOCTOU: the directory may vanish between stat and realpath (review P2-1).
|
|
1537
|
+
// Reject gracefully instead of letting realpathSync throw into the queue
|
|
1538
|
+
// task's .catch (which only logs — the user gets no feedback). Mirrors
|
|
1539
|
+
// handleLsSwitch's realpath guard so /cd and ls.switch behave identically.
|
|
1540
|
+
let canonical;
|
|
1541
|
+
try {
|
|
1542
|
+
canonical = fs.realpathSync(resolved);
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
return { text: `路径无效: ${resolved}` };
|
|
1546
|
+
}
|
|
1547
|
+
// Switch cwd with auto-resume and user feedback (shared with /ws use and ls.switch)
|
|
1548
|
+
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
1549
|
+
}
|
|
1550
|
+
readSessionDisplayState(sessionId, cwd, opts) {
|
|
1551
|
+
// Use the appropriate session reader based on agentKind
|
|
1552
|
+
let reader = this.sessionReader;
|
|
1553
|
+
const agentKind = opts?.agentKind;
|
|
1554
|
+
if (agentKind && agentKind !== this.config.defaultAgent) {
|
|
1555
|
+
reader = this.sessionReaderRegistry.get(agentKind);
|
|
1556
|
+
}
|
|
1557
|
+
const { events, usage, aiTitle, recap, displayTitle } = reader.readSessionContent(sessionId, cwd, { maxEvents: opts?.maxEvents });
|
|
1558
|
+
const activeRun = this.bridge.getActiveRunFor(cwd);
|
|
1559
|
+
// 只检查内存中的 activeRun 状态
|
|
1560
|
+
// 只有当 activeRun 存在且不是终态时,才认为任务仍在进行中
|
|
1561
|
+
const activeRunning = activeRun !== undefined &&
|
|
1562
|
+
activeRun.terminal !== 'done' &&
|
|
1563
|
+
activeRun.terminal !== 'error' &&
|
|
1564
|
+
activeRun.terminal !== 'interrupted' &&
|
|
1565
|
+
activeRun.terminal !== 'idle_timeout' &&
|
|
1566
|
+
activeRun.sessionId === sessionId;
|
|
1567
|
+
const activeUsage = activeRunUsage(activeRun);
|
|
1568
|
+
return {
|
|
1569
|
+
events,
|
|
1570
|
+
usage: activeRunning ? (activeUsage ?? usage) : usage,
|
|
1571
|
+
// 只使用内存中 activeRun 的状态
|
|
1572
|
+
isActive: activeRunning,
|
|
1573
|
+
aiTitle,
|
|
1574
|
+
recap,
|
|
1575
|
+
displayTitle,
|
|
1576
|
+
// Return the real runId only when we have an authoritative in-process
|
|
1577
|
+
// activeRun for THIS session. Callers bind `runId: activeRunRunId`; the
|
|
1578
|
+
// stop button is only rendered when isActive is true, which guarantees
|
|
1579
|
+
// activeRunRunId is set, so bridge.interruptCurrentRun always matches
|
|
1580
|
+
// on the real UUID runId.
|
|
1581
|
+
activeRunRunId: activeRunning ? activeRun?.runId : undefined,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Shared logic for switching cwd with user feedback.
|
|
1586
|
+
* Used by cmdCd, cmdWs use, and handleLsSwitch to ensure consistent behavior:
|
|
1587
|
+
* 1. Set cwd (and clear sessionId per §9.1)
|
|
1588
|
+
* 2. Auto-resume newest session if available
|
|
1589
|
+
* 3. Return appropriate CommandResult (auto-resume card or plain text)
|
|
1590
|
+
*/
|
|
1591
|
+
switchCwdAndNotify(userId, canonical, _ctx) {
|
|
1592
|
+
this.sessionStore.setCwd(userId, canonical);
|
|
1593
|
+
// Auto-resume newest session if exists (P1-15: 读取失败不阻断切换,给可见提示)
|
|
1594
|
+
let newestSession;
|
|
1595
|
+
try {
|
|
1596
|
+
newestSession = this.sessionReader.getNewestSession(canonical);
|
|
1597
|
+
}
|
|
1598
|
+
catch (err) {
|
|
1599
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1600
|
+
return { text: `已切换到: ${canonical}\n(自动恢复失败: ${msg})` };
|
|
1601
|
+
}
|
|
1602
|
+
if (newestSession) {
|
|
1603
|
+
this.sessionStore.setSessionIdAndCwd(userId, this.config.defaultAgent, newestSession.sessionId, canonical);
|
|
1604
|
+
return this.buildAutoResumeCard(canonical, newestSession, _ctx);
|
|
1605
|
+
}
|
|
1606
|
+
return { text: `已切换到: ${canonical}` };
|
|
1607
|
+
}
|
|
1608
|
+
/** Build card for auto-resumed session with new session button */
|
|
1609
|
+
buildAutoResumeCard(cwd, session, _ctx) {
|
|
1610
|
+
const agentName = agentDisplayName(this.config.defaultAgent);
|
|
1611
|
+
const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(session.sessionId, cwd, { maxEvents: AUTO_RESUME_MAX_EVENTS });
|
|
1612
|
+
// Build header with displayTitle (aiTitle or last user message) and recap
|
|
1613
|
+
let header = `📂 \`${cwd}\`\n已恢复最近会话: **${session.sessionId}**`;
|
|
1614
|
+
const sections = [];
|
|
1615
|
+
if (displayTitle) {
|
|
1616
|
+
const label = aiTitle ? 'AI 标题' : '最近输入';
|
|
1617
|
+
sections.push(`🏷️ **${label}**\n${displayTitle}`);
|
|
1618
|
+
}
|
|
1619
|
+
if (recap) {
|
|
1620
|
+
const recapPreview = recap.length > 200 ? recap.slice(0, 197) + '...' : recap;
|
|
1621
|
+
sections.push(`📝 **Recap**\n${recapPreview}`);
|
|
1622
|
+
}
|
|
1623
|
+
if (sections.length > 0) {
|
|
1624
|
+
header += '\n\n' + sections.join('\n\n──\n\n');
|
|
1625
|
+
}
|
|
1626
|
+
const elements = [markdownDiv(header), { tag: 'hr' }];
|
|
1627
|
+
// Fold session history into collapsible panels: the last 2 events stay
|
|
1628
|
+
// expanded so the user sees the most recent context without clicking;
|
|
1629
|
+
// older events are collapsed to keep the card compact.
|
|
1630
|
+
content.forEach((ev, i) => {
|
|
1631
|
+
elements.push(sessionEventPanel(ev, i, content.length, 2, this.config.defaultAgent));
|
|
1632
|
+
});
|
|
1633
|
+
// Add usage/stats at the end
|
|
1634
|
+
if (usage) {
|
|
1635
|
+
const usageStr = formatUsageStats(usage, { showResult: true, result: 'success' });
|
|
1636
|
+
elements.push({
|
|
1637
|
+
tag: 'div',
|
|
1638
|
+
text: { tag: 'lark_md', content: usageStr },
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
// Remove trailing hr and add action buttons
|
|
1642
|
+
if (elements.length > 0 && elements[elements.length - 1].tag === 'hr') {
|
|
1643
|
+
elements.pop();
|
|
1644
|
+
}
|
|
1645
|
+
const actions = [];
|
|
1646
|
+
// Stop button for active sessions (isActive = in-memory activeRun is
|
|
1647
|
+
// non-terminal, which guarantees activeRunRunId is set). Bind the REAL
|
|
1648
|
+
// activeRun.runId (UUID) so bridge.interruptCurrentRun can match and
|
|
1649
|
+
// stop a live run.
|
|
1650
|
+
if (isActive) {
|
|
1651
|
+
actions.push({
|
|
1652
|
+
tag: 'button',
|
|
1653
|
+
text: { tag: 'plain_text', content: '⏹ 终止' },
|
|
1654
|
+
type: 'danger',
|
|
1655
|
+
behaviors: [
|
|
1656
|
+
{
|
|
1657
|
+
type: 'callback',
|
|
1658
|
+
value: { cmd: 'stop', runId: activeRunRunId, cwd },
|
|
1659
|
+
},
|
|
1660
|
+
],
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
// New session button always shown (use shared function for consistency)
|
|
1664
|
+
actions.push(newSessionButton());
|
|
1665
|
+
// Use column_set+column for 2.0 (action tag not supported in 2.0)
|
|
1666
|
+
elements.push({
|
|
1667
|
+
tag: 'column_set',
|
|
1668
|
+
columns: actions.map((btn) => ({
|
|
1669
|
+
tag: 'column',
|
|
1670
|
+
width: 'auto',
|
|
1671
|
+
elements: [btn],
|
|
1672
|
+
})),
|
|
1673
|
+
});
|
|
1674
|
+
const card = {
|
|
1675
|
+
schema: '2.0',
|
|
1676
|
+
config: { wide_screen_mode: true },
|
|
1677
|
+
header: {
|
|
1678
|
+
title: {
|
|
1679
|
+
tag: 'plain_text',
|
|
1680
|
+
content: `${isActive ? '⏳ 自动恢复会话(完成中)' : '🔁 自动恢复会话'} · ${agentName}`,
|
|
1681
|
+
},
|
|
1682
|
+
},
|
|
1683
|
+
body: { elements },
|
|
1684
|
+
};
|
|
1685
|
+
return { card };
|
|
1686
|
+
}
|
|
1687
|
+
static LS_PAGE_SIZE = 30;
|
|
1688
|
+
cmdLs(args, ctx, offset = 0) {
|
|
1689
|
+
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
1690
|
+
if (!cwd) {
|
|
1691
|
+
return { text: '请先使用 /cd <path> 设置工作目录' };
|
|
1692
|
+
}
|
|
1693
|
+
// args can be:
|
|
1694
|
+
// - [] : list current cwd
|
|
1695
|
+
// - [dirName] : list subdirectory if it exists
|
|
1696
|
+
let targetDir = cwd;
|
|
1697
|
+
if (args[0]) {
|
|
1698
|
+
// Expand tilde to home directory (path.resolve does not handle ~)
|
|
1699
|
+
if (args[0].startsWith('~')) {
|
|
1700
|
+
args[0] = path.join(os.homedir(), args[0].slice(1));
|
|
1701
|
+
}
|
|
1702
|
+
// path.resolve (not path.join) so absolute args[0] is honored as-is
|
|
1703
|
+
// instead of being concatenated onto cwd (which broke /ls card nav).
|
|
1704
|
+
const potentialDir = path.resolve(cwd, args[0]);
|
|
1705
|
+
if (!fs.existsSync(potentialDir)) {
|
|
1706
|
+
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
1707
|
+
}
|
|
1708
|
+
if (!fs.statSync(potentialDir).isDirectory()) {
|
|
1709
|
+
return { text: `ls: ${args[0]}: Not a directory` };
|
|
1710
|
+
}
|
|
1711
|
+
targetDir = potentialDir;
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
1715
|
+
// Separate directories and files
|
|
1716
|
+
const dirs = entries
|
|
1717
|
+
.filter((e) => e.isDirectory())
|
|
1718
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1719
|
+
const files = entries.filter((e) => e.isFile()).sort((a, b) => a.name.localeCompare(b.name));
|
|
1720
|
+
// Merge dirs and files for pagination, preserving type info
|
|
1721
|
+
const allItems = [
|
|
1722
|
+
...dirs.map((d) => ({ name: d.name, isDir: true })),
|
|
1723
|
+
...files.map((f) => ({
|
|
1724
|
+
name: f.name,
|
|
1725
|
+
isDir: false,
|
|
1726
|
+
size: fs.statSync(path.join(targetDir, f.name)).size,
|
|
1727
|
+
})),
|
|
1728
|
+
];
|
|
1729
|
+
// Pagination calculations
|
|
1730
|
+
const totalCount = allItems.length;
|
|
1731
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / CommandRouter.LS_PAGE_SIZE));
|
|
1732
|
+
const currentPage = Math.floor(offset / CommandRouter.LS_PAGE_SIZE) + 1;
|
|
1733
|
+
const pageItems = allItems.slice(offset, offset + CommandRouter.LS_PAGE_SIZE);
|
|
1734
|
+
const hasPagination = totalCount > CommandRouter.LS_PAGE_SIZE;
|
|
1735
|
+
// Check if we need to show parent directory button
|
|
1736
|
+
const parentDir = path.dirname(targetDir);
|
|
1737
|
+
const hasParent = parentDir !== targetDir;
|
|
1738
|
+
const isSubdir = targetDir !== cwd;
|
|
1739
|
+
// Build header with parent button
|
|
1740
|
+
const headerElements = [];
|
|
1741
|
+
if (hasParent) {
|
|
1742
|
+
headerElements.push({
|
|
1743
|
+
tag: 'button',
|
|
1744
|
+
text: { tag: 'plain_text', content: '上级' },
|
|
1745
|
+
type: 'default',
|
|
1746
|
+
size: 'small',
|
|
1747
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ls.browse', path: parentDir } }],
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
// Show "返回" button when viewing a subdirectory
|
|
1751
|
+
if (isSubdir) {
|
|
1752
|
+
headerElements.push({
|
|
1753
|
+
tag: 'button',
|
|
1754
|
+
text: { tag: 'plain_text', content: '返回' },
|
|
1755
|
+
type: 'default',
|
|
1756
|
+
size: 'small',
|
|
1757
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ls.browse', path: cwd } }],
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
headerElements.push({
|
|
1761
|
+
tag: 'button',
|
|
1762
|
+
text: { tag: 'plain_text', content: '刷新' },
|
|
1763
|
+
type: 'default',
|
|
1764
|
+
size: 'small',
|
|
1765
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ls.refresh', path: targetDir, offset } }],
|
|
1766
|
+
});
|
|
1767
|
+
// Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
|
|
1768
|
+
if (isSubdir) {
|
|
1769
|
+
headerElements.push({
|
|
1770
|
+
tag: 'button',
|
|
1771
|
+
text: { tag: 'plain_text', content: '切换' },
|
|
1772
|
+
type: 'primary',
|
|
1773
|
+
size: 'small',
|
|
1774
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ls.switch', path: targetDir } }],
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
const formatSize = (bytes) => {
|
|
1778
|
+
if (bytes < 1024)
|
|
1779
|
+
return `${bytes}B`;
|
|
1780
|
+
if (bytes < 1024 * 1024)
|
|
1781
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1782
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1783
|
+
};
|
|
1784
|
+
// Helper to create directory button - use targetDir
|
|
1785
|
+
const dirButton = (name) => ({
|
|
1786
|
+
tag: 'button',
|
|
1787
|
+
text: { tag: 'plain_text', content: `📁 ${name}` },
|
|
1788
|
+
type: 'default',
|
|
1789
|
+
behaviors: [
|
|
1790
|
+
{ type: 'callback', value: { cmd: 'ls.browse', path: path.join(targetDir, name) } },
|
|
1791
|
+
],
|
|
1792
|
+
});
|
|
1793
|
+
// Helper to create file button with size - use targetDir
|
|
1794
|
+
const fileButton = (name, size) => {
|
|
1795
|
+
const sizeStr = size !== undefined ? ` (${formatSize(size)})` : '';
|
|
1796
|
+
return {
|
|
1797
|
+
tag: 'button',
|
|
1798
|
+
text: { tag: 'plain_text', content: `📄 ${name}${sizeStr}` },
|
|
1799
|
+
type: 'default',
|
|
1800
|
+
size: 'small',
|
|
1801
|
+
behaviors: [
|
|
1802
|
+
{ type: 'callback', value: { cmd: 'ls.file', path: path.join(targetDir, name) } },
|
|
1803
|
+
],
|
|
1804
|
+
};
|
|
1805
|
+
};
|
|
1806
|
+
// Build each section's elements (no tabs - use section headers)
|
|
1807
|
+
// Note: div+elements is not supported in CardKit 2.0, use column_set instead
|
|
1808
|
+
const elements = [];
|
|
1809
|
+
// Status line showing directory contents count (with pagination info)
|
|
1810
|
+
const status = hasPagination
|
|
1811
|
+
? `\n共 ${dirs.length} 目录, ${files.length} 文件 · 第 ${currentPage}/${totalPages} 页(共 ${totalCount} 项)`
|
|
1812
|
+
: `\n共 ${dirs.length} 目录, ${files.length} 文件`;
|
|
1813
|
+
// Header info + navigation buttons - show targetDir in header
|
|
1814
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
|
|
1815
|
+
elements.push({
|
|
1816
|
+
tag: 'column_set',
|
|
1817
|
+
columns: headerElements.map((e) => ({ tag: 'column', width: 'auto', elements: [e] })),
|
|
1818
|
+
});
|
|
1819
|
+
elements.push({ tag: 'hr' });
|
|
1820
|
+
// Section 1: Directories (show only items on current page)
|
|
1821
|
+
const pageDirs = pageItems.filter((i) => i.isDir);
|
|
1822
|
+
const pageFiles = pageItems.filter((i) => !i.isDir);
|
|
1823
|
+
elements.push({
|
|
1824
|
+
tag: 'div',
|
|
1825
|
+
text: { tag: 'lark_md', content: `**📂 目录 (${dirs.length})**` },
|
|
1826
|
+
});
|
|
1827
|
+
if (pageDirs.length > 0) {
|
|
1828
|
+
for (const d of pageDirs) {
|
|
1829
|
+
elements.push({
|
|
1830
|
+
tag: 'column_set',
|
|
1831
|
+
flex_mode: 'none',
|
|
1832
|
+
columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
else if (dirs.length === 0) {
|
|
1837
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
|
|
1838
|
+
}
|
|
1839
|
+
else {
|
|
1840
|
+
elements.push({
|
|
1841
|
+
tag: 'div',
|
|
1842
|
+
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无目录)` },
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
elements.push({ tag: 'hr' });
|
|
1846
|
+
// Section 2: Files
|
|
1847
|
+
elements.push({
|
|
1848
|
+
tag: 'div',
|
|
1849
|
+
text: { tag: 'lark_md', content: `**📄 文件 (${files.length})**` },
|
|
1850
|
+
});
|
|
1851
|
+
if (pageFiles.length > 0) {
|
|
1852
|
+
for (const f of pageFiles) {
|
|
1853
|
+
elements.push({
|
|
1854
|
+
tag: 'column_set',
|
|
1855
|
+
flex_mode: 'none',
|
|
1856
|
+
columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
else if (files.length === 0) {
|
|
1861
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
|
|
1862
|
+
}
|
|
1863
|
+
else {
|
|
1864
|
+
elements.push({
|
|
1865
|
+
tag: 'div',
|
|
1866
|
+
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无文件)` },
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
// Pagination bar (only shown when there are more items than PAGE_SIZE)
|
|
1870
|
+
if (hasPagination) {
|
|
1871
|
+
const hasPrev = offset > 0;
|
|
1872
|
+
const hasNext = offset + CommandRouter.LS_PAGE_SIZE < totalCount;
|
|
1873
|
+
// Build columns dynamically — Feishu rejects empty elements[] in a column
|
|
1874
|
+
// (ErrCode 200621: "no tag specified"). Only include columns that have content.
|
|
1875
|
+
const pageColumns = [];
|
|
1876
|
+
if (hasPrev) {
|
|
1877
|
+
pageColumns.push({
|
|
1878
|
+
tag: 'column',
|
|
1879
|
+
width: 'auto',
|
|
1880
|
+
vertical_align: 'center',
|
|
1881
|
+
elements: [
|
|
1882
|
+
{
|
|
1883
|
+
tag: 'button',
|
|
1884
|
+
text: { tag: 'plain_text', content: '⬅ 上一页' },
|
|
1885
|
+
type: 'default',
|
|
1886
|
+
size: 'small',
|
|
1887
|
+
behaviors: [
|
|
1888
|
+
{
|
|
1889
|
+
type: 'callback',
|
|
1890
|
+
value: {
|
|
1891
|
+
cmd: 'ls.page',
|
|
1892
|
+
path: targetDir,
|
|
1893
|
+
offset: offset - CommandRouter.LS_PAGE_SIZE,
|
|
1894
|
+
},
|
|
1895
|
+
},
|
|
1896
|
+
],
|
|
1897
|
+
},
|
|
1898
|
+
],
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
pageColumns.push({
|
|
1902
|
+
tag: 'column',
|
|
1903
|
+
width: 'weighted',
|
|
1904
|
+
weight: 1,
|
|
1905
|
+
vertical_align: 'center',
|
|
1906
|
+
elements: [
|
|
1907
|
+
{
|
|
1908
|
+
tag: 'div',
|
|
1909
|
+
text: {
|
|
1910
|
+
tag: 'lark_md',
|
|
1911
|
+
content: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
|
|
1912
|
+
},
|
|
1913
|
+
},
|
|
1914
|
+
],
|
|
1915
|
+
});
|
|
1916
|
+
if (hasNext) {
|
|
1917
|
+
pageColumns.push({
|
|
1918
|
+
tag: 'column',
|
|
1919
|
+
width: 'auto',
|
|
1920
|
+
vertical_align: 'center',
|
|
1921
|
+
elements: [
|
|
1922
|
+
{
|
|
1923
|
+
tag: 'button',
|
|
1924
|
+
text: { tag: 'plain_text', content: '下一页 ➡' },
|
|
1925
|
+
type: 'default',
|
|
1926
|
+
size: 'small',
|
|
1927
|
+
behaviors: [
|
|
1928
|
+
{
|
|
1929
|
+
type: 'callback',
|
|
1930
|
+
value: {
|
|
1931
|
+
cmd: 'ls.page',
|
|
1932
|
+
path: targetDir,
|
|
1933
|
+
offset: offset + CommandRouter.LS_PAGE_SIZE,
|
|
1934
|
+
},
|
|
1935
|
+
},
|
|
1936
|
+
],
|
|
1937
|
+
},
|
|
1938
|
+
],
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
elements.push({ tag: 'hr' });
|
|
1942
|
+
elements.push({ tag: 'column_set', columns: pageColumns });
|
|
1943
|
+
}
|
|
1944
|
+
return {
|
|
1945
|
+
card: {
|
|
1946
|
+
schema: '2.0',
|
|
1947
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
1948
|
+
header: {
|
|
1949
|
+
title: { tag: 'plain_text', content: `📁 ${path.basename(targetDir)}` },
|
|
1950
|
+
template: 'blue',
|
|
1951
|
+
},
|
|
1952
|
+
body: {
|
|
1953
|
+
elements,
|
|
1954
|
+
},
|
|
1955
|
+
},
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
catch (err) {
|
|
1959
|
+
return { text: `读取目录失败: ${err.message}` };
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
cmdWs(args, ctx) {
|
|
1963
|
+
const sub = args[0]?.toLowerCase();
|
|
1964
|
+
switch (sub) {
|
|
1965
|
+
case 'save': {
|
|
1966
|
+
const name = args[1];
|
|
1967
|
+
if (!name)
|
|
1968
|
+
return { text: '用法: /ws save <name>' };
|
|
1969
|
+
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
1970
|
+
if (!cwd)
|
|
1971
|
+
return { text: '请先 /cd 设置工作目录' };
|
|
1972
|
+
this.workspaceStore.save(name, cwd);
|
|
1973
|
+
return { text: `已保存 workspace "${name}" -> ${cwd}` };
|
|
1974
|
+
}
|
|
1975
|
+
case 'use': {
|
|
1976
|
+
const name = args[1];
|
|
1977
|
+
if (!name)
|
|
1978
|
+
return { text: '用法: /ws use <name>' };
|
|
1979
|
+
const wsPath = this.workspaceStore.get(name);
|
|
1980
|
+
if (!wsPath)
|
|
1981
|
+
return { text: `workspace "${name}" 不存在` };
|
|
1982
|
+
if (!fs.existsSync(wsPath))
|
|
1983
|
+
return { text: `路径不存在: ${wsPath}` };
|
|
1984
|
+
// Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
|
|
1985
|
+
// TOCTOU guard (review P2-1): mirror cmdCd — if the path vanishes
|
|
1986
|
+
// between existsSync and realpath, reply gracefully instead of
|
|
1987
|
+
// throwing into the queue task's silent .catch.
|
|
1988
|
+
let canonical;
|
|
1989
|
+
try {
|
|
1990
|
+
canonical = fs.realpathSync(wsPath);
|
|
1991
|
+
}
|
|
1992
|
+
catch {
|
|
1993
|
+
return { text: `路径无效: ${wsPath}` };
|
|
1994
|
+
}
|
|
1995
|
+
// Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
|
|
1996
|
+
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
1997
|
+
}
|
|
1998
|
+
case 'remove': {
|
|
1999
|
+
const name = args[1];
|
|
2000
|
+
if (!name)
|
|
2001
|
+
return { text: '用法: /ws remove <name>' };
|
|
2002
|
+
if (!this.workspaceStore.has(name))
|
|
2003
|
+
return { text: `workspace "${name}" 不存在` };
|
|
2004
|
+
this.workspaceStore.remove(name);
|
|
2005
|
+
return { text: `已删除 workspace "${name}"` };
|
|
2006
|
+
}
|
|
2007
|
+
case 'list':
|
|
2008
|
+
default: {
|
|
2009
|
+
const entries = this.workspaceStore.list();
|
|
2010
|
+
const currentCwd = this.sessionStore.getCwd(ctx.userId);
|
|
2011
|
+
// Build body elements: current cwd + workspace list with dividers
|
|
2012
|
+
const bodyElements = [];
|
|
2013
|
+
// Current working directory section
|
|
2014
|
+
bodyElements.push({
|
|
2015
|
+
tag: 'div',
|
|
2016
|
+
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
|
|
2017
|
+
});
|
|
2018
|
+
bodyElements.push({ tag: 'hr' });
|
|
2019
|
+
if (entries.length === 0) {
|
|
2020
|
+
bodyElements.push({
|
|
2021
|
+
tag: 'div',
|
|
2022
|
+
text: { tag: 'lark_md', content: '没有保存的 workspace' },
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
else {
|
|
2026
|
+
entries.forEach(([name, p]) => {
|
|
2027
|
+
bodyElements.push({
|
|
2028
|
+
tag: 'div',
|
|
2029
|
+
text: { tag: 'lark_md', content: `**${name}** → \`${p}\`` },
|
|
2030
|
+
});
|
|
2031
|
+
// Use column_set+column for 2.0 (action tag not supported in 2.0)
|
|
2032
|
+
bodyElements.push({
|
|
2033
|
+
tag: 'column_set',
|
|
2034
|
+
columns: [
|
|
2035
|
+
{
|
|
2036
|
+
tag: 'column',
|
|
2037
|
+
width: 'auto',
|
|
2038
|
+
elements: [
|
|
2039
|
+
{
|
|
2040
|
+
tag: 'button',
|
|
2041
|
+
text: { tag: 'plain_text', content: '切换' },
|
|
2042
|
+
type: 'primary',
|
|
2043
|
+
size: 'small',
|
|
2044
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ws.use', name } }],
|
|
2045
|
+
},
|
|
2046
|
+
],
|
|
2047
|
+
},
|
|
2048
|
+
{
|
|
2049
|
+
tag: 'column',
|
|
2050
|
+
width: 'auto',
|
|
2051
|
+
elements: [
|
|
2052
|
+
{
|
|
2053
|
+
tag: 'button',
|
|
2054
|
+
text: { tag: 'plain_text', content: '删除' },
|
|
2055
|
+
type: 'danger',
|
|
2056
|
+
size: 'small',
|
|
2057
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ws.remove', name } }],
|
|
2058
|
+
},
|
|
2059
|
+
],
|
|
2060
|
+
},
|
|
2061
|
+
],
|
|
2062
|
+
});
|
|
2063
|
+
bodyElements.push({ tag: 'hr' });
|
|
2064
|
+
});
|
|
2065
|
+
bodyElements.pop();
|
|
2066
|
+
}
|
|
2067
|
+
const card = {
|
|
2068
|
+
schema: '2.0',
|
|
2069
|
+
config: { wide_screen_mode: true },
|
|
2070
|
+
header: { title: { tag: 'plain_text', content: 'Workspaces' } },
|
|
2071
|
+
body: { elements: bodyElements },
|
|
2072
|
+
};
|
|
2073
|
+
return { card };
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* L3: degraded card when a session's export is non-empty but unparseable
|
|
2079
|
+
* (truncated by opencode's pipe cap on huge sessions, or otherwise corrupt).
|
|
2080
|
+
* The session exists, so reuse listSessions metadata (title) instead of the
|
|
2081
|
+
* misleading "未找到". Reuses the same card-builder helpers (markdownDiv) as
|
|
2082
|
+
* the normal resume card; enforceCardBudget still applies at send time.
|
|
2083
|
+
*/
|
|
2084
|
+
cmdResume(args, ctx, offset = 0) {
|
|
2085
|
+
const entry = this.sessionStore.get(ctx.userId);
|
|
2086
|
+
const cwd = entry?.cwd;
|
|
2087
|
+
// Valid agent kinds for the /resume [agent] [N] feature
|
|
2088
|
+
const VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
|
|
2089
|
+
// Parse arguments: /resume [agent] [N] or /resume [sessionId] or /resume [agent] [sessionId]
|
|
2090
|
+
let agentKind = this.config.defaultAgent;
|
|
2091
|
+
let limit;
|
|
2092
|
+
let sessionIdArg;
|
|
2093
|
+
if (args[0]) {
|
|
2094
|
+
if (VALID_AGENTS.includes(args[0])) {
|
|
2095
|
+
agentKind = args[0];
|
|
2096
|
+
if (args[1]) {
|
|
2097
|
+
if (/^\d+$/.test(args[1])) {
|
|
2098
|
+
limit = parseInt(args[1], 10);
|
|
2099
|
+
}
|
|
2100
|
+
else {
|
|
2101
|
+
sessionIdArg = args[1];
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
else if (/^\d+$/.test(args[0])) {
|
|
2106
|
+
limit = parseInt(args[0], 10);
|
|
2107
|
+
}
|
|
2108
|
+
else {
|
|
2109
|
+
sessionIdArg = args[0];
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
// `/resume <sessionId>` — manually set session id (needs an existing cwd to bind to)
|
|
2113
|
+
if (sessionIdArg) {
|
|
2114
|
+
if (!entry || !cwd)
|
|
2115
|
+
return { text: '请先 /cd 设置工作目录' };
|
|
2116
|
+
const reader = agentKind !== this.config.defaultAgent
|
|
2117
|
+
? this.sessionReaderRegistry.get(agentKind)
|
|
2118
|
+
: this.sessionReader;
|
|
2119
|
+
const verifyContent = reader.readSessionContent(sessionIdArg, cwd);
|
|
2120
|
+
// If session doesn't exist, reject the write
|
|
2121
|
+
if (verifyContent.events.length === 0 &&
|
|
2122
|
+
!verifyContent.usage &&
|
|
2123
|
+
!verifyContent.aiTitle &&
|
|
2124
|
+
!verifyContent.recap &&
|
|
2125
|
+
!verifyContent.displayTitle) {
|
|
2126
|
+
return {
|
|
2127
|
+
text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
// L3: 校验通过后才写入
|
|
2131
|
+
this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
|
|
2132
|
+
const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
|
|
2133
|
+
agentKind,
|
|
2134
|
+
maxEvents: AUTO_RESUME_MAX_EVENTS,
|
|
2135
|
+
});
|
|
2136
|
+
// Build card header with displayTitle (aiTitle or last user message) and recap
|
|
2137
|
+
let header = `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`;
|
|
2138
|
+
const sections = [];
|
|
2139
|
+
if (displayTitle) {
|
|
2140
|
+
const label = aiTitle ? 'AI 标题' : '最近输入';
|
|
2141
|
+
sections.push(`🏷️ **${label}**\n${displayTitle}`);
|
|
2142
|
+
}
|
|
2143
|
+
if (recap) {
|
|
2144
|
+
const recapPreview = recap.length > 200 ? recap.slice(0, 197) + '...' : recap;
|
|
2145
|
+
sections.push(`📝 **Recap**\n${recapPreview}`);
|
|
2146
|
+
}
|
|
2147
|
+
if (sections.length > 0) {
|
|
2148
|
+
header += '\n\n' + sections.join('\n\n──\n\n');
|
|
2149
|
+
}
|
|
2150
|
+
// Build card with session history
|
|
2151
|
+
const elements = [markdownDiv(header), { tag: 'hr' }];
|
|
2152
|
+
// Fold session history into collapsible panels: the last 2 events stay
|
|
2153
|
+
// expanded so the user sees the most recent context without clicking;
|
|
2154
|
+
// older events are collapsed to keep the card compact.
|
|
2155
|
+
if (content.length === 0) {
|
|
2156
|
+
elements.push(markdownDiv('_该会话暂无新消息可显示(最后一条为用户输入)_'));
|
|
2157
|
+
}
|
|
2158
|
+
else {
|
|
2159
|
+
content.forEach((ev, i) => {
|
|
2160
|
+
elements.push(sessionEventPanel(ev, i, content.length, 2, this.config.defaultAgent));
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
// Add usage stats at the end
|
|
2164
|
+
if (usage) {
|
|
2165
|
+
const usageStr = formatUsageStats(usage, { showResult: true, result: 'success' });
|
|
2166
|
+
elements.push(markdownDiv(usageStr));
|
|
2167
|
+
}
|
|
2168
|
+
if (elements.length > 0 && elements[elements.length - 1].tag === 'hr') {
|
|
2169
|
+
elements.pop();
|
|
2170
|
+
}
|
|
2171
|
+
// Add action buttons - use column_set+column for 2.0
|
|
2172
|
+
const buttons = [];
|
|
2173
|
+
// Stop button for active sessions.
|
|
2174
|
+
// isActive is only true when an in-memory activeRun exists, so
|
|
2175
|
+
// activeRunRunId is always set here. See buildAutoResumeCard for details.
|
|
2176
|
+
if (isActive) {
|
|
2177
|
+
buttons.push({
|
|
2178
|
+
tag: 'button',
|
|
2179
|
+
text: { tag: 'plain_text', content: '⏹ 终止' },
|
|
2180
|
+
type: 'danger',
|
|
2181
|
+
behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: activeRunRunId, cwd } }],
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
if (buttons.length > 0) {
|
|
2185
|
+
elements.push({
|
|
2186
|
+
tag: 'column_set',
|
|
2187
|
+
columns: buttons.map((btn) => ({
|
|
2188
|
+
tag: 'column',
|
|
2189
|
+
width: 'auto',
|
|
2190
|
+
elements: [btn],
|
|
2191
|
+
})),
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
const card = {
|
|
2195
|
+
schema: '2.0',
|
|
2196
|
+
config: { wide_screen_mode: true },
|
|
2197
|
+
header: {
|
|
2198
|
+
title: {
|
|
2199
|
+
tag: 'plain_text',
|
|
2200
|
+
content: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
|
|
2201
|
+
},
|
|
2202
|
+
},
|
|
2203
|
+
body: { elements },
|
|
2204
|
+
};
|
|
2205
|
+
return { card };
|
|
2206
|
+
}
|
|
2207
|
+
// `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
|
|
2208
|
+
// Use agentKind to select the appropriate sessionReader
|
|
2209
|
+
if (!cwd)
|
|
2210
|
+
return { text: '请先 /cd 设置工作目录' };
|
|
2211
|
+
const pageSize = Math.min(Math.max(limit ?? RESUME_PAGE_SIZE, 1), RESUME_PAGE_SIZE);
|
|
2212
|
+
let reader;
|
|
2213
|
+
// Use this.sessionReader when agentKind matches default, otherwise get from registry
|
|
2214
|
+
if (agentKind === this.config.defaultAgent) {
|
|
2215
|
+
reader = this.sessionReader;
|
|
2216
|
+
}
|
|
2217
|
+
else {
|
|
2218
|
+
reader = this.sessionReaderRegistry.get(agentKind);
|
|
2219
|
+
}
|
|
2220
|
+
// Fetch the requested page. If the offset is stale (sessions added/removed
|
|
2221
|
+
// since the page was rendered), clamp it to the last valid page start and
|
|
2222
|
+
// re-fetch so the card never shows a misaligned/empty page.
|
|
2223
|
+
let pageOffset = offset;
|
|
2224
|
+
let pageResult;
|
|
2225
|
+
try {
|
|
2226
|
+
pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
|
|
2227
|
+
// 末页起点对齐页边界((总页数-1)*pageSize,如 25 条/页 20 → offset 20),
|
|
2228
|
+
// 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
|
|
2229
|
+
const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
|
|
2230
|
+
if (pageOffset < 0 || pageOffset > maxOffset) {
|
|
2231
|
+
pageOffset = Math.min(Math.max(pageOffset, 0), maxOffset);
|
|
2232
|
+
pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
catch (err) {
|
|
2236
|
+
// P1-15: 读取失败必须给用户可见反馈,而不是误导性的「没有 session 记录」
|
|
2237
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2238
|
+
return { text: `读取 ${agentDisplayName(agentKind)} session 列表失败: ${msg}` };
|
|
2239
|
+
}
|
|
2240
|
+
const { sessions: allSessions, total } = pageResult;
|
|
2241
|
+
if (allSessions.length === 0) {
|
|
2242
|
+
const agentName = agentDisplayName(agentKind);
|
|
2243
|
+
const hint = agentKind === this.config.defaultAgent
|
|
2244
|
+
? `\n提示:可使用 /resume <agent> 切换查看其他 Agent 的 session`
|
|
2245
|
+
: '';
|
|
2246
|
+
return { text: `当前目录没有 ${agentName} session 记录\n${cwd}${hint}` };
|
|
2247
|
+
}
|
|
2248
|
+
// Pre-fetch aiTitle/recap/displayTitle only for the first few rows —
|
|
2249
|
+
// full reads are expensive (whole-file JSONL scan). Remaining rows fall
|
|
2250
|
+
// back to the lightweight summary already returned by listSessions.
|
|
2251
|
+
const sessionMeta = new Map();
|
|
2252
|
+
const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
|
|
2253
|
+
for (const s of allSessions.slice(0, prefetchCount)) {
|
|
2254
|
+
// Use the same reader as the listSessions call above
|
|
2255
|
+
const content = reader.readSessionContent(s.sessionId, cwd);
|
|
2256
|
+
sessionMeta.set(s.sessionId, {
|
|
2257
|
+
aiTitle: content.aiTitle,
|
|
2258
|
+
recap: content.recap,
|
|
2259
|
+
displayTitle: content.displayTitle,
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
const now = Date.now();
|
|
2263
|
+
function formatRelativeTime(mtimeMs) {
|
|
2264
|
+
const diffSec = Math.floor((now - mtimeMs) / 1000);
|
|
2265
|
+
if (diffSec < 60)
|
|
2266
|
+
return '刚刚';
|
|
2267
|
+
if (diffSec < 3600)
|
|
2268
|
+
return `${Math.floor(diffSec / 60)} 分钟前`;
|
|
2269
|
+
if (diffSec < 86400)
|
|
2270
|
+
return `${Math.floor(diffSec / 3600)} 小时前`;
|
|
2271
|
+
if (diffSec < 604800)
|
|
2272
|
+
return `${Math.floor(diffSec / 86400)} 天前`;
|
|
2273
|
+
return new Date(mtimeMs).toLocaleDateString('zh-CN');
|
|
2274
|
+
}
|
|
2275
|
+
const elements = [
|
|
2276
|
+
{
|
|
2277
|
+
tag: 'div',
|
|
2278
|
+
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${cwd}\`` },
|
|
2279
|
+
},
|
|
2280
|
+
];
|
|
2281
|
+
// Get current sessionId for the agent being listed
|
|
2282
|
+
const currentId = entry?.sessions?.get(agentKind) ?? '';
|
|
2283
|
+
for (const s of allSessions) {
|
|
2284
|
+
// Only mark as current if we're listing the defaultAgent's sessions
|
|
2285
|
+
const isCurrent = agentKind === this.config.defaultAgent && s.sessionId === currentId;
|
|
2286
|
+
const timeStr = formatRelativeTime(s.mtime);
|
|
2287
|
+
const buttonText = isCurrent ? '✓ 当前会话' : '恢复此会话';
|
|
2288
|
+
const meta = sessionMeta.get(s.sessionId);
|
|
2289
|
+
// Title priority:
|
|
2290
|
+
// 1. Prefetched displayTitle (aiTitle or last user message) keeps its
|
|
2291
|
+
// original label (AI 标题 / 最近输入).
|
|
2292
|
+
// 2. Otherwise the lightweight summary renders under the neutral
|
|
2293
|
+
// "会话摘要" label — summary is the first user message, not the
|
|
2294
|
+
// last input, so "最近输入" would be semantically wrong.
|
|
2295
|
+
// 3. Placeholder summaries (no user message) render no title section
|
|
2296
|
+
// at all — never leak '(无摘要)'/'(no user message)'/'New Session'.
|
|
2297
|
+
const summaryIsPlaceholder = RESUME_SUMMARY_PLACEHOLDERS.has(s.summary.trim());
|
|
2298
|
+
const prefetchedTitle = meta?.displayTitle;
|
|
2299
|
+
// aiTitle 不能单独作为"预取标题"键:若 displayTitle 缺失(理论上 opencode
|
|
2300
|
+
// info.title 非空但 export 无 user text),标题兜底是 summary,label 必须
|
|
2301
|
+
// 是中性"会话摘要"而不是"最近输入"(Review R3 P2-1 边缘分支)。
|
|
2302
|
+
const titleText = prefetchedTitle ?? (summaryIsPlaceholder ? '' : s.summary);
|
|
2303
|
+
const titleLabel = prefetchedTitle
|
|
2304
|
+
? meta?.aiTitle
|
|
2305
|
+
? 'AI 标题'
|
|
2306
|
+
: '最近输入'
|
|
2307
|
+
: summaryIsPlaceholder
|
|
2308
|
+
? ''
|
|
2309
|
+
: '会话摘要';
|
|
2310
|
+
const sections = [];
|
|
2311
|
+
if (titleText) {
|
|
2312
|
+
sections.push(`🏷️ **${titleLabel}**\n${titleText.slice(0, 80)}${titleText.length > 80 ? '...' : ''}`);
|
|
2313
|
+
}
|
|
2314
|
+
if (meta?.recap) {
|
|
2315
|
+
sections.push(`📝 **Recap**\n${meta.recap.slice(0, 60)}${meta.recap.length > 60 ? '...' : ''}`);
|
|
2316
|
+
}
|
|
2317
|
+
const infoStr = sections.length > 0 ? '\n' + sections.join('\n──\n') : '';
|
|
2318
|
+
elements.push({ tag: 'hr' });
|
|
2319
|
+
// Both current and non-current sessions get a button for visual consistency
|
|
2320
|
+
elements.push({
|
|
2321
|
+
tag: 'div',
|
|
2322
|
+
text: {
|
|
2323
|
+
tag: 'lark_md',
|
|
2324
|
+
content: `**${s.sessionId}**${infoStr}\n⏱ ${timeStr}${isCurrent ? ' •' : ''}`,
|
|
2325
|
+
},
|
|
2326
|
+
});
|
|
2327
|
+
elements.push({
|
|
2328
|
+
tag: 'column_set',
|
|
2329
|
+
columns: [
|
|
2330
|
+
{
|
|
2331
|
+
tag: 'column',
|
|
2332
|
+
width: 'auto',
|
|
2333
|
+
elements: [
|
|
2334
|
+
{
|
|
2335
|
+
tag: 'button',
|
|
2336
|
+
text: { tag: 'plain_text', content: buttonText },
|
|
2337
|
+
type: isCurrent ? 'default' : 'primary',
|
|
2338
|
+
disabled: isCurrent,
|
|
2339
|
+
behaviors: [
|
|
2340
|
+
{
|
|
2341
|
+
type: 'callback',
|
|
2342
|
+
value: { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind },
|
|
2343
|
+
},
|
|
2344
|
+
],
|
|
2345
|
+
},
|
|
2346
|
+
],
|
|
2347
|
+
},
|
|
2348
|
+
],
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
// Pagination bar (only shown when there are more sessions than pageSize)
|
|
2352
|
+
if (total > pageSize) {
|
|
2353
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
2354
|
+
const currentPage = Math.floor(pageOffset / pageSize) + 1;
|
|
2355
|
+
const hasPrev = pageOffset > 0;
|
|
2356
|
+
const hasNext = pageOffset + pageSize < total;
|
|
2357
|
+
// Build columns dynamically — Feishu rejects empty elements[] in a column
|
|
2358
|
+
// (ErrCode 200621: "no tag specified"). Only include columns that have content.
|
|
2359
|
+
const pageColumns = [];
|
|
2360
|
+
if (hasPrev) {
|
|
2361
|
+
pageColumns.push({
|
|
2362
|
+
tag: 'column',
|
|
2363
|
+
width: 'auto',
|
|
2364
|
+
vertical_align: 'center',
|
|
2365
|
+
elements: [
|
|
2366
|
+
{
|
|
2367
|
+
tag: 'button',
|
|
2368
|
+
text: { tag: 'plain_text', content: '⬅ 上一页' },
|
|
2369
|
+
type: 'default',
|
|
2370
|
+
size: 'small',
|
|
2371
|
+
behaviors: [
|
|
2372
|
+
{
|
|
2373
|
+
type: 'callback',
|
|
2374
|
+
value: {
|
|
2375
|
+
cmd: 'resume.page',
|
|
2376
|
+
agent: agentKind,
|
|
2377
|
+
offset: pageOffset - pageSize,
|
|
2378
|
+
pageSize,
|
|
2379
|
+
},
|
|
2380
|
+
},
|
|
2381
|
+
],
|
|
2382
|
+
},
|
|
2383
|
+
],
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
pageColumns.push({
|
|
2387
|
+
tag: 'column',
|
|
2388
|
+
width: 'weighted',
|
|
2389
|
+
weight: 1,
|
|
2390
|
+
vertical_align: 'center',
|
|
2391
|
+
elements: [
|
|
2392
|
+
{
|
|
2393
|
+
tag: 'div',
|
|
2394
|
+
text: {
|
|
2395
|
+
tag: 'lark_md',
|
|
2396
|
+
content: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
|
|
2397
|
+
},
|
|
2398
|
+
},
|
|
2399
|
+
],
|
|
2400
|
+
});
|
|
2401
|
+
if (hasNext) {
|
|
2402
|
+
pageColumns.push({
|
|
2403
|
+
tag: 'column',
|
|
2404
|
+
width: 'auto',
|
|
2405
|
+
vertical_align: 'center',
|
|
2406
|
+
elements: [
|
|
2407
|
+
{
|
|
2408
|
+
tag: 'button',
|
|
2409
|
+
text: { tag: 'plain_text', content: '下一页 ➡' },
|
|
2410
|
+
type: 'default',
|
|
2411
|
+
size: 'small',
|
|
2412
|
+
behaviors: [
|
|
2413
|
+
{
|
|
2414
|
+
type: 'callback',
|
|
2415
|
+
value: {
|
|
2416
|
+
cmd: 'resume.page',
|
|
2417
|
+
agent: agentKind,
|
|
2418
|
+
offset: pageOffset + pageSize,
|
|
2419
|
+
pageSize,
|
|
2420
|
+
},
|
|
2421
|
+
},
|
|
2422
|
+
],
|
|
2423
|
+
},
|
|
2424
|
+
],
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
elements.push({ tag: 'hr' });
|
|
2428
|
+
elements.push({ tag: 'column_set', columns: pageColumns });
|
|
2429
|
+
}
|
|
2430
|
+
const card = {
|
|
2431
|
+
schema: '2.0',
|
|
2432
|
+
config: { wide_screen_mode: true },
|
|
2433
|
+
header: {
|
|
2434
|
+
title: {
|
|
2435
|
+
tag: 'plain_text',
|
|
2436
|
+
content: `🔁 恢复历史会话 · ${agentDisplayName(agentKind)}`,
|
|
2437
|
+
},
|
|
2438
|
+
},
|
|
2439
|
+
body: { elements },
|
|
2440
|
+
};
|
|
2441
|
+
return { card };
|
|
2442
|
+
}
|
|
2443
|
+
cmdActive(_args, _ctx) {
|
|
2444
|
+
// List all active runs from bridge memory (not from file system scan)
|
|
2445
|
+
// New semantics (2026-07-20): only shows runs started by THIS bridge process
|
|
2446
|
+
const activeRuns = this.bridge.getActiveRuns();
|
|
2447
|
+
const activeBashRuns = this.bridge.getActiveBashRuns();
|
|
2448
|
+
if (activeRuns.length === 0 && activeBashRuns.length === 0) {
|
|
2449
|
+
return { text: '当前没有正在进行中的任务' };
|
|
2450
|
+
}
|
|
2451
|
+
// Build CardKit 2.0 card showing memory-based active runs
|
|
2452
|
+
return this.buildActiveCardFromMemory(activeRuns, activeBashRuns);
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Build CardKit 2.0 /active card from bridge memory (not file system).
|
|
2456
|
+
* Shows only runs that were started by THIS bridge process.
|
|
2457
|
+
*/
|
|
2458
|
+
buildActiveCardFromMemory(activeRuns, activeBashRuns) {
|
|
2459
|
+
const elements = [];
|
|
2460
|
+
// Agent runs section
|
|
2461
|
+
if (activeRuns.length > 0) {
|
|
2462
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 🤖 Agent 任务' } });
|
|
2463
|
+
for (const run of activeRuns) {
|
|
2464
|
+
const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
|
|
2465
|
+
elements.push({
|
|
2466
|
+
tag: 'div',
|
|
2467
|
+
text: { tag: 'lark_md', content: `**📂 ${run.cwd.split('/').pop() ?? run.cwd}**` },
|
|
2468
|
+
});
|
|
2469
|
+
elements.push({
|
|
2470
|
+
tag: 'div',
|
|
2471
|
+
text: {
|
|
2472
|
+
tag: 'lark_md',
|
|
2473
|
+
content: `> session: ${run.sessionId.slice(0, 12)}... \n> 状态: ${statusLabel}`,
|
|
2474
|
+
},
|
|
2475
|
+
});
|
|
2476
|
+
// Stop button - CardKit 2.0 with behaviors callback
|
|
2477
|
+
elements.push({
|
|
2478
|
+
tag: 'button',
|
|
2479
|
+
text: { tag: 'plain_text', content: '⏹ 停止' },
|
|
2480
|
+
type: 'danger',
|
|
2481
|
+
size: 'small',
|
|
2482
|
+
behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: run.runId, cwd: run.cwd } }],
|
|
2483
|
+
});
|
|
2484
|
+
elements.push({ tag: 'hr' });
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
// Bash runs section
|
|
2488
|
+
if (activeBashRuns.length > 0) {
|
|
2489
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 💻 Bash 命令' } });
|
|
2490
|
+
for (const run of activeBashRuns) {
|
|
2491
|
+
const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
|
|
2492
|
+
elements.push({
|
|
2493
|
+
tag: 'div',
|
|
2494
|
+
text: { tag: 'lark_md', content: `**\`${run.command}\`**` },
|
|
2495
|
+
});
|
|
2496
|
+
elements.push({
|
|
2497
|
+
tag: 'div',
|
|
2498
|
+
text: { tag: 'lark_md', content: `> 目录: ${run.cwd} \n> 状态: ${statusLabel}` },
|
|
2499
|
+
});
|
|
2500
|
+
// Stop button - CardKit 2.0 with behaviors callback
|
|
2501
|
+
elements.push({
|
|
2502
|
+
tag: 'button',
|
|
2503
|
+
text: { tag: 'plain_text', content: '⏹ 停止' },
|
|
2504
|
+
type: 'danger',
|
|
2505
|
+
size: 'small',
|
|
2506
|
+
behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: run.runId, cwd: run.cwd } }],
|
|
2507
|
+
});
|
|
2508
|
+
elements.push({ tag: 'hr' });
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
const card = {
|
|
2512
|
+
schema: '2.0',
|
|
2513
|
+
config: { wide_screen_mode: true },
|
|
2514
|
+
header: {
|
|
2515
|
+
title: { tag: 'plain_text', content: '🔄 进行中的任务' },
|
|
2516
|
+
template: 'blue',
|
|
2517
|
+
},
|
|
2518
|
+
body: { elements },
|
|
2519
|
+
};
|
|
2520
|
+
return { card };
|
|
2521
|
+
}
|
|
2522
|
+
async cmdReconnect() {
|
|
2523
|
+
try {
|
|
2524
|
+
await this.bridge.reconnect();
|
|
2525
|
+
return { text: '已重连飞书' };
|
|
2526
|
+
}
|
|
2527
|
+
catch {
|
|
2528
|
+
return { text: '重连失败,请检查网络和配置' };
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
cmdConfig(args, ctx) {
|
|
2532
|
+
// Check if this is a direct set command: /config <key> <value>
|
|
2533
|
+
// Command-style set: immediate write to disk, clear pendingConfig
|
|
2534
|
+
if (args.length >= 2) {
|
|
2535
|
+
const key = args[0];
|
|
2536
|
+
const value = args.slice(1).join(' ');
|
|
2537
|
+
try {
|
|
2538
|
+
const oldDefaultAgent = this.config.defaultAgent;
|
|
2539
|
+
// P2-23:直写前若 pendingConfig 存在未保存差异,提示用户被丢弃的条数,
|
|
2540
|
+
// 避免静默清空导致用户在卡片上的修改无反馈丢失。差异必须在 setConfigValue
|
|
2541
|
+
// 之前计算——直写本身也会改变 config,不应计入「被丢弃的未保存修改」。
|
|
2542
|
+
const discarded = this.pendingConfig
|
|
2543
|
+
? Object.keys(this.diffConfig(this.config, this.pendingConfig)).length
|
|
2544
|
+
: 0;
|
|
2545
|
+
this.config = setConfigValue(this.configPath, this.config, key, value);
|
|
2546
|
+
// P1-6:直写路径与 config.save 卡片路径共用运行时传播(idleTimeout /
|
|
2547
|
+
// clearRunners / syncAgentChoices / defaultAgent 切换)。key 经 mapAgentKey
|
|
2548
|
+
// 归一化,pi./codex./opencode./kimi. 命中 agents.* 判定;claude. 与
|
|
2549
|
+
// defaultAgent 不再漏判。
|
|
2550
|
+
this.propagateConfigSave(oldDefaultAgent, { [mapAgentKey(key)]: value }, ctx);
|
|
2551
|
+
this.pendingConfig = null; // 命令式写盘后清空暂存,避免不一致
|
|
2552
|
+
const card = this.buildConfigCard();
|
|
2553
|
+
if (discarded > 0 && card.card && typeof card.card === 'object') {
|
|
2554
|
+
const body = card.card.body;
|
|
2555
|
+
if (body && Array.isArray(body.elements)) {
|
|
2556
|
+
body.elements.unshift({
|
|
2557
|
+
tag: 'div',
|
|
2558
|
+
text: { tag: 'lark_md', content: `⚠️ 已丢弃 ${discarded} 项未保存修改` },
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
return card;
|
|
2563
|
+
}
|
|
2564
|
+
catch (err) {
|
|
2565
|
+
return { text: `设置失败: ${err.message}` };
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
// Default: show interactive config card - initialize pendingConfig if needed
|
|
2569
|
+
this.ensurePendingConfig();
|
|
2570
|
+
return this.buildConfigCard();
|
|
2571
|
+
}
|
|
2572
|
+
/** Build an interactive config card with CardKit 2.0 tabs + batch save */
|
|
2573
|
+
buildConfigCard() {
|
|
2574
|
+
// 使用 pendingConfig(若存在)或当前 config
|
|
2575
|
+
const displayConfig = this.pendingConfig ?? this.config;
|
|
2576
|
+
// 定义字段分组:defaultAgent 选择器 + 该 agent 的配置字段 + idle.watchdogMinutes +
|
|
2577
|
+
// output/logging。stopGraceMs / claude.binary 等实现细节走 YAML,不在卡片暴露。
|
|
2578
|
+
// 根据 defaultAgent 构建完整的 Agent 配置字段组
|
|
2579
|
+
// 使用 agent config builder 获取该 agent 的配置字段
|
|
2580
|
+
const defaultAgent = displayConfig.defaultAgent ?? 'claude';
|
|
2581
|
+
const configBuilder = getConfigBuilder(defaultAgent);
|
|
2582
|
+
const agentConfigFields = configBuilder.buildFields(displayConfig);
|
|
2583
|
+
// 使用共享渲染模块构建 tabs 和卡片
|
|
2584
|
+
// 添加 defaultAgent 选择器作为第一个字段
|
|
2585
|
+
agentConfigFields.unshift({
|
|
2586
|
+
key: 'defaultAgent',
|
|
2587
|
+
label: '默认 Agent',
|
|
2588
|
+
type: 'select',
|
|
2589
|
+
options: listRegisteredAgents(),
|
|
2590
|
+
});
|
|
2591
|
+
const tabs = [
|
|
2592
|
+
{
|
|
2593
|
+
id: 'agent',
|
|
2594
|
+
label: `🤖 ${agentDisplayName(defaultAgent)}`,
|
|
2595
|
+
fields: agentConfigFields,
|
|
2596
|
+
},
|
|
2597
|
+
{
|
|
2598
|
+
id: 'idle',
|
|
2599
|
+
label: '⏱️ 空闲',
|
|
2600
|
+
fields: [{ key: 'idle.watchdogMinutes', label: '空闲超时(分钟, 0关闭)', type: 'input' }],
|
|
2601
|
+
},
|
|
2602
|
+
{
|
|
2603
|
+
id: 'output',
|
|
2604
|
+
label: '📤 输出',
|
|
2605
|
+
fields: [
|
|
2606
|
+
{ key: 'output.showThinking', label: '显示思考过程', type: 'boolean' },
|
|
2607
|
+
{ key: 'output.showToolUse', label: '显示工具调用', type: 'boolean' },
|
|
2608
|
+
{ key: 'output.showToolResult', label: '显示工具结果', type: 'boolean' },
|
|
2609
|
+
],
|
|
2610
|
+
},
|
|
2611
|
+
{
|
|
2612
|
+
id: 'logging',
|
|
2613
|
+
label: '📝 日志',
|
|
2614
|
+
fields: [
|
|
2615
|
+
{
|
|
2616
|
+
key: 'logging.level',
|
|
2617
|
+
label: '日志级别',
|
|
2618
|
+
type: 'select',
|
|
2619
|
+
options: ['debug', 'info', 'warn', 'error'],
|
|
2620
|
+
},
|
|
2621
|
+
],
|
|
2622
|
+
},
|
|
2623
|
+
];
|
|
2624
|
+
// 使用共享渲染模块构建卡片
|
|
2625
|
+
const card = buildConfigCardFromTabs(tabs, displayConfig);
|
|
2626
|
+
return { card };
|
|
2627
|
+
}
|
|
2628
|
+
cmdOrder(args, _ctx) {
|
|
2629
|
+
const sub = args[0]?.toLowerCase();
|
|
2630
|
+
// /order — list orders (simple CardKit 2.0 list, no tabs)
|
|
2631
|
+
if (!sub || sub === 'list') {
|
|
2632
|
+
this.orderStore.reload();
|
|
2633
|
+
const allOrders = this.orderStore.get();
|
|
2634
|
+
const elements = [];
|
|
2635
|
+
if (allOrders.length === 0) {
|
|
2636
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '暂无指令' } });
|
|
2637
|
+
}
|
|
2638
|
+
else {
|
|
2639
|
+
for (let i = 0; i < allOrders.length; i++) {
|
|
2640
|
+
const order = allOrders[i];
|
|
2641
|
+
const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
|
|
2642
|
+
// 文字在上方
|
|
2643
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: displayText } });
|
|
2644
|
+
// 按钮在下方,一左一右
|
|
2645
|
+
elements.push({
|
|
2646
|
+
tag: 'column_set',
|
|
2647
|
+
columns: [
|
|
2648
|
+
{
|
|
2649
|
+
tag: 'column',
|
|
2650
|
+
width: 'auto',
|
|
2651
|
+
elements: [
|
|
2652
|
+
{
|
|
2653
|
+
tag: 'button',
|
|
2654
|
+
text: { tag: 'plain_text', content: '▶ 执行' },
|
|
2655
|
+
type: 'primary',
|
|
2656
|
+
size: 'small',
|
|
2657
|
+
behaviors: [
|
|
2658
|
+
{ type: 'callback', value: { cmd: 'order.exec', orderId: order.id } },
|
|
2659
|
+
],
|
|
2660
|
+
},
|
|
2661
|
+
],
|
|
2662
|
+
},
|
|
2663
|
+
{
|
|
2664
|
+
tag: 'column',
|
|
2665
|
+
width: 'auto',
|
|
2666
|
+
elements: [
|
|
2667
|
+
{
|
|
2668
|
+
tag: 'button',
|
|
2669
|
+
text: { tag: 'plain_text', content: '删除' },
|
|
2670
|
+
type: 'danger',
|
|
2671
|
+
size: 'small',
|
|
2672
|
+
behaviors: [
|
|
2673
|
+
{ type: 'callback', value: { cmd: 'order.delete', orderId: order.id } },
|
|
2674
|
+
],
|
|
2675
|
+
},
|
|
2676
|
+
],
|
|
2677
|
+
},
|
|
2678
|
+
],
|
|
2679
|
+
});
|
|
2680
|
+
// 非最后一项,添加分隔线
|
|
2681
|
+
if (i < allOrders.length - 1) {
|
|
2682
|
+
elements.push({ tag: 'hr' });
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
return {
|
|
2687
|
+
card: {
|
|
2688
|
+
schema: '2.0',
|
|
2689
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
2690
|
+
header: { title: { tag: 'plain_text', content: '📋 指令' }, template: 'turquoise' },
|
|
2691
|
+
body: { elements },
|
|
2692
|
+
},
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
// /order save <text>
|
|
2696
|
+
if (sub === 'save') {
|
|
2697
|
+
const text = args.slice(1).join(' ');
|
|
2698
|
+
if (!text) {
|
|
2699
|
+
return { text: '用法: /order save <指令文本>' };
|
|
2700
|
+
}
|
|
2701
|
+
try {
|
|
2702
|
+
this.orderStore.save(text);
|
|
2703
|
+
return { text: `✅ 已保存指令: ${text.slice(0, 50)}${text.length > 50 ? '...' : ''}` };
|
|
2704
|
+
}
|
|
2705
|
+
catch (err) {
|
|
2706
|
+
return { text: `保存失败: ${err.message}` };
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return { text: '用法: /order [list|save <text>]' };
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
// --- Re-exported from ./utils.js (moved for decomposition) ---
|
|
2713
|
+
// formatUsageStats is shared by run-renderer and bridge via ../router/index.js.
|
|
2714
|
+
export { formatUsageStats } from './utils.js';
|
|
2715
|
+
//# sourceMappingURL=index.js.map
|