lark-remote 0.5.12 → 0.5.14
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 +28 -2
- package/README.en.md +1 -14
- package/README.md +1 -14
- package/dist/agent-kinds.d.ts +27 -0
- package/dist/agent-kinds.js +38 -0
- package/dist/bridge/approval-actions.d.ts +95 -0
- package/dist/bridge/approval-actions.js +104 -0
- package/dist/bridge/approval-coordinator.d.ts +1 -0
- package/dist/bridge/approval-coordinator.js +12 -24
- package/dist/bridge/final-usage.d.ts +8 -17
- package/dist/bridge/final-usage.js +1 -18
- package/dist/bridge/index.d.ts +52 -38
- package/dist/bridge/index.js +255 -433
- package/dist/bridge/queue-manager.d.ts +25 -1
- package/dist/bridge/queue-manager.js +179 -160
- package/dist/bridge/run-approval.d.ts +16 -0
- package/dist/bridge/run-approval.js +41 -0
- package/dist/bridge/run-usage.d.ts +32 -0
- package/dist/bridge/run-usage.js +87 -0
- package/dist/bridge/session-writeback.d.ts +19 -0
- package/dist/bridge/session-writeback.js +36 -0
- package/dist/card/approval-render.d.ts +3 -2
- package/dist/card/approval-render.js +3 -2
- package/dist/card/bash-card-session.js +2 -5
- package/dist/{router → card}/card-helpers.d.ts +7 -7
- package/dist/{router → card}/card-helpers.js +8 -8
- package/dist/card/card-session.d.ts +6 -2
- package/dist/card/card-session.js +28 -28
- package/dist/card/card-shared.d.ts +4 -4
- package/dist/card/card-shared.js +4 -10
- package/dist/card/collapsible.js +3 -2
- package/dist/card/final-answer-overflow.d.ts +1 -0
- package/dist/card/final-answer-overflow.js +46 -0
- package/dist/card/run-card-session.d.ts +2 -2
- package/dist/card/run-card-session.js +4 -9
- package/dist/card/run-renderer.d.ts +14 -0
- package/dist/card/run-renderer.js +126 -23
- package/dist/card/run-state.d.ts +40 -51
- package/dist/card/run-state.js +96 -34
- package/dist/card/usage-format.d.ts +30 -0
- package/dist/{router → card}/usage-format.js +3 -10
- package/dist/clone.js +6 -1
- package/dist/config/codex-config.js +4 -4
- package/dist/config/index.d.ts +18 -20
- package/dist/config/index.js +6 -20
- package/dist/config/pi-config.js +6 -4
- package/dist/connector/index.d.ts +2 -4
- package/dist/connector/index.js +7 -11
- package/dist/{runner/dsh → dsh}/auth.js +3 -4
- package/dist/dsh/home.d.ts +2 -0
- package/dist/dsh/home.js +8 -0
- package/dist/dsh/protocol.d.ts +9 -0
- package/dist/dsh/protocol.js +61 -0
- package/dist/inbound/turn-assembler.js +6 -0
- package/dist/index.js +25 -414
- package/dist/message-handlers.d.ts +26 -0
- package/dist/message-handlers.js +275 -0
- package/dist/model-catalog.d.ts +14 -0
- package/dist/model-catalog.js +25 -0
- package/dist/platform/path.d.ts +9 -0
- package/dist/platform/path.js +18 -0
- package/dist/router/active-run-usage.d.ts +4 -0
- package/dist/router/active-run-usage.js +12 -0
- package/dist/router/card-action-payload.d.ts +1 -1
- package/dist/router/card-action-payload.js +1 -1
- package/dist/router/commands/ls.d.ts +9 -0
- package/dist/router/commands/ls.js +243 -0
- package/dist/router/commands/order.d.ts +12 -0
- package/dist/router/commands/order.js +178 -0
- package/dist/router/commands/resume.d.ts +48 -0
- package/dist/router/commands/resume.js +246 -0
- package/dist/router/commands/ws.d.ts +15 -0
- package/dist/router/commands/ws.js +189 -0
- package/dist/router/config/claude.js +5 -4
- package/dist/router/config/codex.js +2 -6
- package/dist/router/config/common/render.d.ts +5 -1
- package/dist/router/config/common/render.js +6 -13
- package/dist/router/config/common/turn-idle.d.ts +2 -0
- package/dist/router/config/common/turn-idle.js +9 -0
- package/dist/router/config/dsh.d.ts +1 -1
- package/dist/router/config/index.d.ts +1 -1
- package/dist/router/config/kimi.js +2 -6
- package/dist/router/config/opencode.js +2 -6
- package/dist/router/config/types.d.ts +11 -3
- package/dist/router/index.d.ts +55 -33
- package/dist/router/index.js +233 -1376
- package/dist/router/paging.d.ts +13 -0
- package/dist/router/paging.js +20 -0
- package/dist/router/path-validation.d.ts +27 -0
- package/dist/router/path-validation.js +45 -0
- package/dist/runner/agent-choices-common.d.ts +1 -1
- package/dist/runner/claude/runner.d.ts +23 -6
- package/dist/runner/claude/runner.js +6 -20
- package/dist/runner/claude/session.js +1 -1
- package/dist/runner/codex/app-server/runner.d.ts +3 -1
- package/dist/runner/codex/app-server/translator.d.ts +13 -0
- package/dist/runner/codex/app-server/translator.js +114 -151
- package/dist/runner/common/acp/base-acp-runner.d.ts +3 -1
- package/dist/runner/common/acp/base-translator.d.ts +3 -8
- package/dist/runner/common/acp/base-translator.js +16 -7
- package/dist/runner/common/connection-based-runner.d.ts +3 -1
- package/dist/runner/common/jsonrpc/transport.js +17 -7
- package/dist/runner/common/spawning-runner.js +10 -4
- package/dist/runner/common/turn-started.d.ts +2 -0
- package/dist/runner/common/turn-started.js +10 -0
- package/dist/runner/dsh/client.d.ts +2 -10
- package/dist/runner/dsh/client.js +13 -220
- package/dist/runner/dsh/daemon.d.ts +0 -2
- package/dist/runner/dsh/daemon.js +9 -7
- package/dist/runner/dsh/errors.d.ts +5 -0
- package/dist/runner/dsh/errors.js +11 -0
- package/dist/runner/dsh/exit-cancel.d.ts +0 -2
- package/dist/runner/dsh/exit-cancel.js +2 -2
- package/dist/runner/dsh/inflight.d.ts +0 -2
- package/dist/runner/dsh/inflight.js +1 -1
- package/dist/runner/dsh/mux-stream.d.ts +26 -0
- package/dist/runner/dsh/mux-stream.js +176 -0
- package/dist/runner/dsh/runner.d.ts +21 -1
- package/dist/runner/dsh/runner.js +76 -73
- package/dist/runner/dsh/translator.d.ts +1 -11
- package/dist/runner/dsh/translator.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.d.ts +22 -1
- package/dist/runner/kimi/acp/runner.js +83 -60
- package/dist/runner/kimi/acp/translator.js +1 -12
- package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
- package/dist/runner/opencode/acp/error-monitor.js +31 -2
- package/dist/runner/opencode/acp/runner.d.ts +3 -1
- package/dist/runner/opencode/acp/translator.js +1 -12
- package/dist/runner/pi/rpc/runner.d.ts +2 -1
- package/dist/runner/pi/rpc/translator.d.ts +2 -8
- package/dist/runner/pi/rpc/translator.js +2 -7
- package/dist/runner/probe.d.ts +1 -1
- package/dist/runner/probe.js +10 -9
- package/dist/runner/registry.d.ts +2 -20
- package/dist/runner/registry.js +0 -32
- package/dist/runner/types.d.ts +37 -155
- package/dist/runner/types.js +4 -24
- package/dist/session/claude/session-index.d.ts +11 -4
- package/dist/session/claude/session-index.js +34 -10
- package/dist/session/claude/session-reader.d.ts +1 -1
- package/dist/session/claude/sessions.d.ts +2 -2
- package/dist/session/claude/sessions.js +41 -26
- package/dist/session/codex/rollout-reader.d.ts +1 -1
- package/dist/session/codex/rollout-reader.js +14 -13
- package/dist/session/codex/sessions.d.ts +1 -1
- package/dist/session/codex/sessions.js +4 -2
- package/dist/session/common/jsonl.js +7 -1
- package/dist/session/common/pagination.d.ts +2 -0
- package/dist/session/common/pagination.js +1 -1
- package/dist/session/common/recency.d.ts +6 -0
- package/dist/session/common/recency.js +7 -5
- package/dist/session/common/two-pass.d.ts +1 -1
- package/dist/session/common/usage-accumulator.d.ts +1 -1
- package/dist/session/dsh/sessions.d.ts +1 -1
- package/dist/session/dsh/sessions.js +37 -12
- package/dist/session/kimi/sessions.d.ts +1 -1
- package/dist/session/kimi/sessions.js +37 -10
- package/dist/session/opencode/sessions.d.ts +1 -1
- package/dist/session/opencode/sessions.js +53 -48
- package/dist/session/pi/sessions.d.ts +1 -1
- package/dist/session/pi/sessions.js +23 -9
- package/dist/session/registry.d.ts +2 -1
- package/dist/session/session-store.d.ts +1 -2
- package/dist/session/session-store.js +14 -7
- package/dist/session-contract.d.ts +178 -0
- package/dist/session-contract.js +9 -0
- package/package.json +1 -1
- package/dist/router/usage-format.d.ts +0 -60
- /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
- /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
- /package/dist/{runner/dsh → dsh}/types.js +0 -0
package/dist/router/index.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import YAML from 'yaml';
|
|
5
|
-
import { getConfigDir,
|
|
4
|
+
import { getConfigDir, setConfigValue, setConfigValues, mapAgentKey, getAgentConfig, } from '../config/index.js';
|
|
6
5
|
import { syncAgentChoices } from '../runner/index.js';
|
|
7
|
-
import { resolveDshCredentialsFile } from '../
|
|
6
|
+
import { resolveDshCredentialsFile } from '../dsh/auth.js';
|
|
8
7
|
import { WorkspaceStore } from '../workspace/index.js';
|
|
9
8
|
import { OrderStore, ORDER_NOT_FOUND_TEXT } from '../order/index.js';
|
|
10
9
|
import { resolveAlias } from '../order/alias-resolve.js';
|
|
11
10
|
import { diffConfig, setNestedValue } from './config-diff.js';
|
|
12
|
-
import { AGENT_KINDS } from '../
|
|
11
|
+
import { AGENT_KINDS, resolveAgentKind } from '../agent-kinds.js';
|
|
13
12
|
import { getLogger } from '../logger/index.js';
|
|
14
13
|
import { stripPlaceholders } from '../inbound/placeholder.js';
|
|
15
14
|
import { clampInt } from '../common/clamp.js';
|
|
16
|
-
import { activeRunUsage } from './usage
|
|
17
|
-
import { markdownDiv, buildSessionHistoryCard, paginationBar,
|
|
15
|
+
import { activeRunUsage } from './active-run-usage.js';
|
|
16
|
+
import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from '../card/card-helpers.js';
|
|
18
17
|
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
19
18
|
import { cardButton, newSessionButton, resumeCompactButton, agentDisplayName, } from '../card/card-shared.js';
|
|
20
19
|
import { displayName } from '../platform/path.js';
|
|
@@ -24,36 +23,16 @@ import { checkLatestVersion as defaultCheckLatestVersion, compareVersions as def
|
|
|
24
23
|
import { getConfigBuilder, listRegisteredAgents, sortAgentsForDisplay } from './config/index.js';
|
|
25
24
|
import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
|
|
26
25
|
import { buildConfigCardFromTabs } from './config/common/render.js';
|
|
26
|
+
import { isDirectorySafe, resolveDirectory, statPath } from './path-validation.js';
|
|
27
|
+
import { pageInfo } from './paging.js';
|
|
28
|
+
import { buildLsCommand, LS_PAGE_SIZE } from './commands/ls.js';
|
|
29
|
+
import { buildWsCommand, WS_PAGE_SIZE } from './commands/ws.js';
|
|
30
|
+
import { buildOrderCommand, ORDER_PAGE_SIZE } from './commands/order.js';
|
|
31
|
+
import { buildResumeCommand, AUTO_RESUME_MAX_EVENTS, RESUME_PAGE_SIZE } from './commands/resume.js';
|
|
27
32
|
/** Max events to read for auto-resume and /resume cards.
|
|
28
33
|
* Limits events at the read stage so enforceCardBudget rarely needs to truncate.
|
|
29
34
|
* Chosen to be under the 28KB card budget for typical events (~2KB each). */
|
|
30
|
-
const AUTO_RESUME_MAX_EVENTS = 5;
|
|
31
|
-
/** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
|
|
32
|
-
const RESUME_PAGE_SIZE = 5;
|
|
33
35
|
/** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
|
|
34
|
-
/**
|
|
35
|
-
* 分页算术(原先 5 处内联且 clamp 语义漂移:/ls 不 clamp、/active clamp 到
|
|
36
|
-
* totalCount-1 会把过期 offset 泄漏成残页)。统一 clamp 到最后一页边界。
|
|
37
|
-
*/
|
|
38
|
-
function pageInfo(totalCount, offset, pageSize) {
|
|
39
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
40
|
-
const maxOffset = Math.max(0, (totalPages - 1) * pageSize);
|
|
41
|
-
const safeOffset = clampInt(offset, 0, maxOffset);
|
|
42
|
-
const currentPage = Math.floor(safeOffset / pageSize) + 1;
|
|
43
|
-
return { totalPages, safeOffset, currentPage };
|
|
44
|
-
}
|
|
45
|
-
/** 在 pageInfo 之上切出当前页条目。 */
|
|
46
|
-
function pageSlice(items, offset, pageSize) {
|
|
47
|
-
const { totalPages, safeOffset, currentPage } = pageInfo(items.length, offset, pageSize);
|
|
48
|
-
return {
|
|
49
|
-
totalCount: items.length,
|
|
50
|
-
totalPages,
|
|
51
|
-
safeOffset,
|
|
52
|
-
currentPage,
|
|
53
|
-
pageItems: items.slice(safeOffset, safeOffset + pageSize),
|
|
54
|
-
hasPagination: items.length > pageSize,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
36
|
const ACTIVE_PAGE_SIZE = 20;
|
|
58
37
|
/**
|
|
59
38
|
* 飞书会把极短间隔连发的多条消息合并成一条换行分隔的消息:用户侧看是两条消息,
|
|
@@ -75,15 +54,6 @@ function findMergedCommandTail(message) {
|
|
|
75
54
|
return hasSlashLine ? tail : null;
|
|
76
55
|
}
|
|
77
56
|
/** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
|
|
78
|
-
function formatBytes(bytes) {
|
|
79
|
-
if (bytes < 1024)
|
|
80
|
-
return `${bytes}B`;
|
|
81
|
-
if (bytes < 1024 * 1024)
|
|
82
|
-
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
83
|
-
if (bytes < 1024 * 1024 * 1024)
|
|
84
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
85
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
86
|
-
}
|
|
87
57
|
/**
|
|
88
58
|
* /order 列表页大小;指令超过此数量时显示分页导航栏。
|
|
89
59
|
* 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
|
|
@@ -93,13 +63,11 @@ function formatBytes(bytes) {
|
|
|
93
63
|
* 从 15 降为 8:3*8 - 1 + 2 = 25 个元素,仍在余量内,同时保证手机窄屏
|
|
94
64
|
* 每条指令的操作控件(执行/别名/删除)不被挤压。
|
|
95
65
|
*/
|
|
96
|
-
const ORDER_PAGE_SIZE = 8;
|
|
97
66
|
/**
|
|
98
67
|
* /ws 列表页大小。从 15 降为 5:头部 2 + 5 行 × 3(div + column_set + hr)+
|
|
99
68
|
* 底栏排序 1 + 分页栏 2 ≈ 20 个 body 元素,远低于单卡 60 元素上限(ErrCode 11310),
|
|
100
69
|
* 留足余量。
|
|
101
70
|
*/
|
|
102
|
-
const WS_PAGE_SIZE = 5;
|
|
103
71
|
/**
|
|
104
72
|
* 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
|
|
105
73
|
* 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
|
|
@@ -126,25 +94,17 @@ function normalizePageArgs(value, maxPageSize) {
|
|
|
126
94
|
return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
|
|
127
95
|
}
|
|
128
96
|
/** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
|
|
129
|
-
const RESUME_CONTENT_PREFETCH = 5;
|
|
130
97
|
/**
|
|
131
98
|
* Reader summary 占位符(trim 后匹配)。各 reader 在"无 user 消息"时返回的
|
|
132
99
|
* 兜底文案不同(claude/pi `(无摘要)`、codex `(no user message)`、kimi
|
|
133
100
|
* `New Session`、空串),都不是真实输入,不得渲染成行标题。
|
|
134
101
|
*/
|
|
135
|
-
const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
|
|
136
|
-
/** 列表卡「最近输入 / 最后输出」单行预览的长度上限(中文按字符,手机约两行)。 */
|
|
137
|
-
const RESUME_PREVIEW_MAX = 60;
|
|
138
102
|
/**
|
|
139
103
|
* 列表卡单行预览:压平空白、去掉反引号,再按字符截断。
|
|
140
104
|
*
|
|
141
105
|
* 为什么去反引号:agent 的输出里 `code` 很常见,截断点落在一对反引号中间时
|
|
142
106
|
* 后半张卡片的 markdown 会被当成代码块渲染(lark_md 不闭合就不回退)。
|
|
143
107
|
*/
|
|
144
|
-
function sessionPreviewLine(text, max) {
|
|
145
|
-
const flat = text.replace(/`/g, '').replace(/\s+/g, ' ').trim();
|
|
146
|
-
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
147
|
-
}
|
|
148
108
|
/**
|
|
149
109
|
* Card-action commands that must run with enqueue({ immediate: true }).
|
|
150
110
|
* These bypass the serial queue and execute immediately.
|
|
@@ -330,7 +290,6 @@ const IMMEDIATE_ACTION_CMDS = new Set([
|
|
|
330
290
|
'order.aliasRemove',
|
|
331
291
|
'order.textEdit',
|
|
332
292
|
'order.textInput',
|
|
333
|
-
'config.toggle',
|
|
334
293
|
'config.set',
|
|
335
294
|
'config.input',
|
|
336
295
|
'config.save',
|
|
@@ -369,20 +328,6 @@ export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
|
|
|
369
328
|
function payloadOffset(value) {
|
|
370
329
|
return Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
371
330
|
}
|
|
372
|
-
/**
|
|
373
|
-
* existsSync + statSync 的 TOCTOU 安全目录校验:路径在两次 syscalls 之间被删时
|
|
374
|
-
* statSync 会抛错——落进 enqueueImmediate / 队列任务的静默 catch,点击/命令
|
|
375
|
-
* 毫无反馈(违反「点击必有反馈」红线;同族 handleLsRefresh/Page/Filter 均已
|
|
376
|
-
* try/catch 防护)。统一返回 false 走「路径无效」回复。
|
|
377
|
-
*/
|
|
378
|
-
function isDirectorySafe(p) {
|
|
379
|
-
try {
|
|
380
|
-
return fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
331
|
/**
|
|
387
332
|
* 搜索框提交值单源读取:`inputValue` 主路(CardKit 2.0 input 提交(键盘回车/
|
|
388
333
|
* 完成键)经 raw `action.input_value` 回传,SDK normalizer 会丢弃
|
|
@@ -403,13 +348,20 @@ const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
|
|
|
403
348
|
* 卡片只有最近一两张,5 张足够覆盖;无上限会让长跑进程的 Set 随卡片数增长。
|
|
404
349
|
*/
|
|
405
350
|
const MAX_CONFIG_CARDS = 5;
|
|
351
|
+
/** 本次改动是否落进 `agents.<kind>` 子树。 */
|
|
352
|
+
function touchesAgentsSubtree(keys, kind) {
|
|
353
|
+
return keys.some((k) => k === `agents.${kind}` || k.startsWith(`agents.${kind}.`));
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 本次改动是否触及某个 agent 的配置——两种键形态都要认:现行 `agents.<kind>[.*]`
|
|
357
|
+
* 与历史的顶层 `<kind>[.*]`(claude 至今是顶层键)。判定曾抄在三处(agent 变更检测 /
|
|
358
|
+
* agentChoices 同步 / approval 热推),每加一个 agent 都得记得改三遍,与
|
|
359
|
+
* AGENT_KINDS 名单漂移是同一种事故,这里收成一处。
|
|
360
|
+
*/
|
|
361
|
+
function touchesAgentConfig(keys, kind) {
|
|
362
|
+
return (touchesAgentsSubtree(keys, kind) || keys.some((k) => k === kind || k.startsWith(`${kind}.`)));
|
|
363
|
+
}
|
|
406
364
|
export class CommandRouter {
|
|
407
|
-
/**
|
|
408
|
-
* Valid agent kinds — single source of truth for resume.use / resume.page /
|
|
409
|
-
* cmdResume. 名单本体在 `runner/types.ts` 的 AGENT_KINDS(与 AgentKind 类型
|
|
410
|
-
* 同源),这里只做别名,避免人手抄第二份。
|
|
411
|
-
*/
|
|
412
|
-
static VALID_AGENTS = AGENT_KINDS;
|
|
413
365
|
sessionStore;
|
|
414
366
|
/** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
|
|
415
367
|
bridge;
|
|
@@ -482,8 +434,8 @@ export class CommandRouter {
|
|
|
482
434
|
/**
|
|
483
435
|
* 串行化 config.* 卡片回调(2026-07-04)。
|
|
484
436
|
* CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,**不进串行队列**,
|
|
485
|
-
*
|
|
486
|
-
* `updateCardInPlace` → Feishu API
|
|
437
|
+
* 多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
|
|
438
|
+
* `updateCardInPlace` → Feishu API 乱序到达,控件卡死在某个状态。
|
|
487
439
|
* 此 Promise chain 把所有 config.* 动作串成一个执行链,保证后到的动作
|
|
488
440
|
* 等前一个动作的卡片 patch 完成后再读 `pendingConfig`。
|
|
489
441
|
*/
|
|
@@ -637,11 +589,7 @@ export class CommandRouter {
|
|
|
637
589
|
case 'resume.use': {
|
|
638
590
|
// resume.use carries an agent field from /resume [agent] list cards
|
|
639
591
|
// (both active and completed cards include the agent field).
|
|
640
|
-
const
|
|
641
|
-
const validAgents = CommandRouter.VALID_AGENTS;
|
|
642
|
-
const resolvedAgent = validAgents.includes(resumeAgent)
|
|
643
|
-
? resumeAgent
|
|
644
|
-
: this.config.defaultAgent;
|
|
592
|
+
const resolvedAgent = resolveAgentKind(value.agent, this.config.defaultAgent);
|
|
645
593
|
const entry = this.sessionStore.get(ctx.userId);
|
|
646
594
|
const cwd = entry?.cwd;
|
|
647
595
|
if (!cwd) {
|
|
@@ -712,13 +660,12 @@ export class CommandRouter {
|
|
|
712
660
|
return await this.handleOrderTextEdit(value, ctx);
|
|
713
661
|
case 'order.textInput':
|
|
714
662
|
return await this.handleOrderTextInput(value, ctx);
|
|
715
|
-
case 'config.toggle':
|
|
716
663
|
case 'config.set':
|
|
717
664
|
case 'config.input':
|
|
718
665
|
case 'config.save':
|
|
719
666
|
// 2026-07-04: 串行化所有 config.* 动作。CardKit 2.0 回调经 enqueueImmediate
|
|
720
|
-
//
|
|
721
|
-
// 调 updateCardInPlace → Feishu API
|
|
667
|
+
// 不进串行队列,多次快速点击下拉/文本框会并发读写 pendingConfig 并同时
|
|
668
|
+
// 调 updateCardInPlace → Feishu API 乱序到达导致控件卡死。
|
|
722
669
|
// 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
|
|
723
670
|
return this.enqueueConfigAction(value, ctx);
|
|
724
671
|
case 'compact':
|
|
@@ -813,6 +760,20 @@ export class CommandRouter {
|
|
|
813
760
|
* Handle queue.immediate: stop current run, clear queue before this message, execute this message immediately.
|
|
814
761
|
* Instead of asking user to resend, we keep the target task in queue and let it execute.
|
|
815
762
|
*/
|
|
763
|
+
/**
|
|
764
|
+
* 把「执行编辑后的内容」注册成原队列槽位的一次性 replacement(复用入队时
|
|
765
|
+
* 捕获的 binding + 恢复 lane cwd,否则编辑后重跑的 run 会丢 cwd 与绑定)。
|
|
766
|
+
*
|
|
767
|
+
* 重新入队不行:`enqueue` 只能追加到 promise 链尾部,会把编辑任务甩到
|
|
768
|
+
* 后来者后面,违背「立即执行 = 下一个就跑」。三个调用点都必须在任何 await
|
|
769
|
+
* 之前注册——begin 路径消费 replacement 的时机不可预测(见
|
|
770
|
+
* handleQueueImmediate 步骤 1.5 的竞态说明)。
|
|
771
|
+
*/
|
|
772
|
+
registerEditedReplacement(cwd, messageId, content, binding, ctx) {
|
|
773
|
+
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
774
|
+
await this.handle(content, ctx, { cwdOverride: cwd, binding });
|
|
775
|
+
});
|
|
776
|
+
}
|
|
816
777
|
async handleQueueImmediate(value, ctx) {
|
|
817
778
|
const payload = await this.queuePayloadOrReply(value, ctx);
|
|
818
779
|
if (!payload)
|
|
@@ -862,15 +823,7 @@ export class CommandRouter {
|
|
|
862
823
|
// the queue position: enqueue can only append to the promise chain tail,
|
|
863
824
|
// which would put the edited task behind tasks queued after it.
|
|
864
825
|
if (targetTask.editedMessage) {
|
|
865
|
-
|
|
866
|
-
///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
|
|
867
|
-
// cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
|
|
868
|
-
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
869
|
-
await this.handle(editedContent, ctx, {
|
|
870
|
-
cwdOverride: cwd,
|
|
871
|
-
binding: targetTask.binding,
|
|
872
|
-
});
|
|
873
|
-
});
|
|
826
|
+
this.registerEditedReplacement(cwd, messageId, targetTask.editedMessage, targetTask.binding, ctx);
|
|
874
827
|
}
|
|
875
828
|
// 2. Stop current running process
|
|
876
829
|
// (Note: queue executing count is reset inside Bridge.interruptCurrentRun
|
|
@@ -887,29 +840,7 @@ export class CommandRouter {
|
|
|
887
840
|
const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
|
|
888
841
|
getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
|
|
889
842
|
// 3. Remove all tasks BEFORE this one (by position in the current queue).
|
|
890
|
-
|
|
891
|
-
// advances concurrently): then it is already out of the snapshot and every
|
|
892
|
-
// remaining queued task is BEHIND it — clearing must stop, not run off the
|
|
893
|
-
// end and delete tasks queued after the target.
|
|
894
|
-
const tasks = this.bridge.getQueuedTasks(cwd);
|
|
895
|
-
const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
|
|
896
|
-
let removedCount = 0;
|
|
897
|
-
// Phase 1: remove synchronously. The queue chain advances on any await
|
|
898
|
-
// (card PATCH round trips included), so every task before the target must
|
|
899
|
-
// be removed before yielding to the event loop — otherwise a task the user
|
|
900
|
-
// asked to clear can begin executing while its cancellation card is still
|
|
901
|
-
// in flight.
|
|
902
|
-
for (let i = 0; i < targetIdx; i++) {
|
|
903
|
-
const task = tasks[i];
|
|
904
|
-
// 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
|
|
905
|
-
// 同步推进的既有不变式。
|
|
906
|
-
if (this.bridge.removeFromQueue(cwd, task.messageId)) {
|
|
907
|
-
removedCount++;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
if (targetIdx < 0) {
|
|
911
|
-
getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
|
|
912
|
-
}
|
|
843
|
+
const removedCount = this.clearTasksAheadOfTarget(cwd, messageId);
|
|
913
844
|
// 4. DO NOT remove the target task - keep it in queue to execute immediately
|
|
914
845
|
// The queue will naturally execute it after the current task (which we just stopped)
|
|
915
846
|
// 4.4. Refresh the replacement with the latest edited content (idempotent:
|
|
@@ -918,66 +849,92 @@ export class CommandRouter {
|
|
|
918
849
|
// the task has begun, because getQueuedTask then returns undefined.
|
|
919
850
|
const latestTask = this.bridge.getQueuedTask(cwd, messageId);
|
|
920
851
|
if (latestTask?.editedMessage) {
|
|
921
|
-
|
|
922
|
-
///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
|
|
923
|
-
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
924
|
-
await this.handle(editedContent, ctx, {
|
|
925
|
-
cwdOverride: cwd,
|
|
926
|
-
binding: latestTask.binding,
|
|
927
|
-
});
|
|
928
|
-
});
|
|
852
|
+
this.registerEditedReplacement(cwd, messageId, latestTask.editedMessage, latestTask.binding, ctx);
|
|
929
853
|
}
|
|
930
|
-
// 4.5/6.
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
854
|
+
// 4.5/6. 按「目标此刻真的是下一个吗」给唯一一份用户可见反馈。
|
|
855
|
+
await this.reportImmediateOutcome({
|
|
856
|
+
cwd,
|
|
857
|
+
messageId,
|
|
858
|
+
ctx,
|
|
859
|
+
removedCount,
|
|
860
|
+
stopped,
|
|
861
|
+
stopUnconfirmed,
|
|
862
|
+
agentKind: interrupt.agentKind,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
finally {
|
|
866
|
+
releaseRunStart();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* 清掉排在目标之前的所有排队任务,返回实际清除条数。
|
|
871
|
+
*
|
|
872
|
+
* 两条红线:
|
|
873
|
+
* - 目标可能在 interruptCurrentRun 等待期间已经接跑(链并发前进),那时它已不在
|
|
874
|
+
* 快照里,剩下的全部排在它**后面**——清除必须在目标位置之前停下,不得越界删掉
|
|
875
|
+
* 用户没要求清除的任务。
|
|
876
|
+
* - 移除循环必须整体同步:队列链在任何 await 处都会推进(卡片 PATCH 也算),
|
|
877
|
+
* 一旦让出事件循环,用户要求清除的任务可能趁撤销卡还在路上就跑起来了。
|
|
878
|
+
* 撤销卡片由 `removeFromQueue` 统一触发(fire-and-forget)。
|
|
879
|
+
*/
|
|
880
|
+
clearTasksAheadOfTarget(cwd, messageId) {
|
|
881
|
+
const tasks = this.bridge.getQueuedTasks(cwd);
|
|
882
|
+
const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
|
|
883
|
+
let removedCount = 0;
|
|
884
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
885
|
+
if (this.bridge.removeFromQueue(cwd, tasks[i].messageId)) {
|
|
886
|
+
removedCount++;
|
|
961
887
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
888
|
+
}
|
|
889
|
+
if (targetIdx < 0) {
|
|
890
|
+
getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
|
|
891
|
+
}
|
|
892
|
+
return removedCount;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* `queue.immediate` 的终态反馈(唯一出口)。
|
|
896
|
+
*
|
|
897
|
+
* 承诺必须与真实调度一致:目标只有确实是停止后的下一个,才可以把它的卡翻成
|
|
898
|
+
* 「执行中」并说「将立即执行」;在途接跑/被撤销/被抢先/远程停止未确认四种状态
|
|
899
|
+
* 各给各的文案(把没发生的事说成发生了,比沉默更糟——用户会据文案决定要不要重发)。
|
|
900
|
+
*/
|
|
901
|
+
async reportImmediateOutcome(opts) {
|
|
902
|
+
const { cwd, messageId, ctx, removedCount, stopped, stopUnconfirmed, agentKind } = opts;
|
|
903
|
+
const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
|
|
904
|
+
if (!targetStillQueued) {
|
|
905
|
+
// 目标在途接跑:已经在执行,说「无需重复操作」;真的被撤销了才说「未安排执行」。
|
|
906
|
+
if (this.bridge.hasTaskBegan(messageId)) {
|
|
907
|
+
await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
|
|
971
908
|
}
|
|
972
909
|
else {
|
|
973
910
|
await this.bridge.sendResult({
|
|
974
|
-
text: `⚠️
|
|
911
|
+
text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
|
|
975
912
|
}, ctx);
|
|
976
913
|
}
|
|
914
|
+
return;
|
|
977
915
|
}
|
|
978
|
-
|
|
979
|
-
|
|
916
|
+
if (stopUnconfirmed) {
|
|
917
|
+
const name = agentKind ? agentDisplayName(agentKind) : 'agent';
|
|
918
|
+
await this.bridge.sendResult({
|
|
919
|
+
text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
|
|
920
|
+
`已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
|
|
921
|
+
}, ctx);
|
|
922
|
+
return;
|
|
980
923
|
}
|
|
924
|
+
if (!this.bridge.isBusyFor(cwd)) {
|
|
925
|
+
// Normal path: nothing new began during the stop, the target is next.
|
|
926
|
+
// Mark its own card as executing now so buttons grey out immediately.
|
|
927
|
+
// Done BEFORE any removal so getQueuedTask still returns the task with
|
|
928
|
+
// its (possibly edited) messagePreview for the card. The queue callback's
|
|
929
|
+
// later updateQueueCardToExecuting call becomes a no-op (idempotent).
|
|
930
|
+
await this.bridge.markQueueCardExecuting(cwd, messageId);
|
|
931
|
+
const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
|
|
932
|
+
await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
await this.bridge.sendResult({
|
|
936
|
+
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
937
|
+
}, ctx);
|
|
981
938
|
}
|
|
982
939
|
/**
|
|
983
940
|
* Handle queue.diagnose: show diagnostic info for why message is queuing.
|
|
@@ -1149,8 +1106,6 @@ export class CommandRouter {
|
|
|
1149
1106
|
// replacement if the task starts during the subsequent await. Both
|
|
1150
1107
|
// setTaskReplacement and the begin-path consumption are synchronous, so
|
|
1151
1108
|
// there is no interleaving window.
|
|
1152
|
-
//
|
|
1153
|
-
///Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
|
|
1154
1109
|
const inputTask = this.bridge.getQueuedTask(cwd, messageId);
|
|
1155
1110
|
if (!inputTask) {
|
|
1156
1111
|
// Task already left the queue (began or was cancelled) before we could
|
|
@@ -1160,9 +1115,7 @@ export class CommandRouter {
|
|
|
1160
1115
|
// toast/card field).
|
|
1161
1116
|
return { toast: { type: 'info', content: '任务已不在队列中(可能已开始执行或被撤销)' } };
|
|
1162
1117
|
}
|
|
1163
|
-
this.
|
|
1164
|
-
await this.handle(newMessage, ctx, { cwdOverride: cwd, binding: inputTask.binding });
|
|
1165
|
-
});
|
|
1118
|
+
this.registerEditedReplacement(cwd, messageId, newMessage, inputTask.binding, ctx);
|
|
1166
1119
|
// Update the message preview (and editedMessage so handleQueueImmediate
|
|
1167
1120
|
// re-enqueues the edited content instead of the stale original closure),
|
|
1168
1121
|
// and build the updated queue card. Returning the card in the callback
|
|
@@ -1226,26 +1179,17 @@ export class CommandRouter {
|
|
|
1226
1179
|
const resolvedTarget = path.resolve(targetPath);
|
|
1227
1180
|
// 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
|
|
1228
1181
|
// 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
// Canonicalize via realpath; if the target vanished between stat and
|
|
1234
|
-
// realpath (TOCTOU), reject gracefully instead of throwing.
|
|
1235
|
-
let canonical;
|
|
1236
|
-
try {
|
|
1237
|
-
canonical = fs.realpathSync(resolvedTarget);
|
|
1238
|
-
}
|
|
1239
|
-
catch {
|
|
1182
|
+
const resolution = resolveDirectory(resolvedTarget);
|
|
1183
|
+
if (!resolution.ok) {
|
|
1240
1184
|
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
1241
1185
|
return;
|
|
1242
1186
|
}
|
|
1243
1187
|
// Set cwd + auto-resume + user notification (shared with /cd and /ws use)
|
|
1244
|
-
const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
1188
|
+
const notifyResult = this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
|
|
1245
1189
|
// Card channel: refresh ls card in-place (existing behavior preserved)。
|
|
1246
1190
|
// value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
|
|
1247
1191
|
// 「返回」仍回到用户最初 /ls 的那个目录。
|
|
1248
|
-
const card = this.cmdLs([canonical], ctx, 0, value.root);
|
|
1192
|
+
const card = this.cmdLs([resolution.canonical], ctx, 0, value.root);
|
|
1249
1193
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1250
1194
|
// Message channel: send switch confirmation / auto-resume card to chat
|
|
1251
1195
|
await this.bridge.sendResult(notifyResult, ctx);
|
|
@@ -1256,21 +1200,11 @@ export class CommandRouter {
|
|
|
1256
1200
|
async handleLsRefresh(value, ctx) {
|
|
1257
1201
|
const targetPath = value.path;
|
|
1258
1202
|
const offset = value.offset ?? 0;
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
let card;
|
|
1265
|
-
try {
|
|
1266
|
-
card =
|
|
1267
|
-
targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
|
1268
|
-
? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
|
|
1269
|
-
: this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1270
|
-
}
|
|
1271
|
-
catch {
|
|
1272
|
-
card = this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1273
|
-
}
|
|
1203
|
+
// Treat a vanished/non-dir path as "use current cwd" instead of crashing
|
|
1204
|
+
// the refresh. inspectPath closes the former existsSync→statSync race.
|
|
1205
|
+
const card = targetPath && isDirectorySafe(targetPath)
|
|
1206
|
+
? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
|
|
1207
|
+
: this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1274
1208
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1275
1209
|
}
|
|
1276
1210
|
/**
|
|
@@ -1281,22 +1215,12 @@ export class CommandRouter {
|
|
|
1281
1215
|
if (!targetPath) {
|
|
1282
1216
|
return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
|
|
1283
1217
|
}
|
|
1284
|
-
const paging = this.pagingOffset(value,
|
|
1218
|
+
const paging = this.pagingOffset(value, LS_PAGE_SIZE);
|
|
1285
1219
|
if ('error' in paging) {
|
|
1286
1220
|
return { toast: { type: 'error', content: paging.error } };
|
|
1287
1221
|
}
|
|
1288
1222
|
const resolvedTarget = path.resolve(targetPath);
|
|
1289
|
-
|
|
1290
|
-
// try/catch the throw lands in enqueueImmediate's silent .catch and the
|
|
1291
|
-
// paginated card never updates (no visible feedback for the click).
|
|
1292
|
-
let isDir;
|
|
1293
|
-
try {
|
|
1294
|
-
isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
|
|
1295
|
-
}
|
|
1296
|
-
catch {
|
|
1297
|
-
isDir = false;
|
|
1298
|
-
}
|
|
1299
|
-
if (!isDir) {
|
|
1223
|
+
if (!isDirectorySafe(resolvedTarget)) {
|
|
1300
1224
|
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1301
1225
|
}
|
|
1302
1226
|
const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root, value.q);
|
|
@@ -1318,14 +1242,7 @@ export class CommandRouter {
|
|
|
1318
1242
|
}
|
|
1319
1243
|
const q = searchQueryFrom(value);
|
|
1320
1244
|
const resolvedTarget = path.resolve(targetPath);
|
|
1321
|
-
|
|
1322
|
-
try {
|
|
1323
|
-
isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
|
|
1324
|
-
}
|
|
1325
|
-
catch {
|
|
1326
|
-
isDir = false;
|
|
1327
|
-
}
|
|
1328
|
-
if (!isDir) {
|
|
1245
|
+
if (!isDirectorySafe(resolvedTarget)) {
|
|
1329
1246
|
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1330
1247
|
}
|
|
1331
1248
|
const card = this.cmdLs([resolvedTarget], ctx, 0, value.root, q);
|
|
@@ -1337,11 +1254,7 @@ export class CommandRouter {
|
|
|
1337
1254
|
* Mirrors handleLsPage — updates the same card, never sends a new one.
|
|
1338
1255
|
*/
|
|
1339
1256
|
async handleResumePage(value, ctx) {
|
|
1340
|
-
const
|
|
1341
|
-
const validAgents = CommandRouter.VALID_AGENTS;
|
|
1342
|
-
const resolvedAgent = validAgents.includes(resumeAgent)
|
|
1343
|
-
? resumeAgent
|
|
1344
|
-
: this.config.defaultAgent;
|
|
1257
|
+
const resolvedAgent = resolveAgentKind(value.agent, this.config.defaultAgent);
|
|
1345
1258
|
const { pageSize, alignedOffset, error } = normalizePageArgs(value, RESUME_PAGE_SIZE);
|
|
1346
1259
|
if (error) {
|
|
1347
1260
|
return { toast: { type: 'error', content: error } };
|
|
@@ -1471,8 +1384,6 @@ export class CommandRouter {
|
|
|
1471
1384
|
* Returns null when the order no longer exists; index.ts surfaces the error.
|
|
1472
1385
|
*/
|
|
1473
1386
|
resolveOrderExecForQueue(orderId) {
|
|
1474
|
-
if (!this.orderStore.has(orderId))
|
|
1475
|
-
return null;
|
|
1476
1387
|
const order = this.orderStore.get().find((o) => o.id === orderId);
|
|
1477
1388
|
if (!order)
|
|
1478
1389
|
return null;
|
|
@@ -1856,16 +1767,13 @@ export class CommandRouter {
|
|
|
1856
1767
|
* `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
|
|
1857
1768
|
*/
|
|
1858
1769
|
async deliverFileToFeishu(resolved, ctx) {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
try {
|
|
1864
|
-
stat = fs.statSync(resolved);
|
|
1865
|
-
}
|
|
1866
|
-
catch {
|
|
1770
|
+
const resolution = statPath(resolved);
|
|
1771
|
+
if (!resolution.ok) {
|
|
1772
|
+
if (resolution.reason === 'missing')
|
|
1773
|
+
return { text: `文件不存在: ${resolved}` };
|
|
1867
1774
|
return { text: `无法读取: ${resolved}` };
|
|
1868
1775
|
}
|
|
1776
|
+
const { stat } = resolution;
|
|
1869
1777
|
if (stat.isDirectory()) {
|
|
1870
1778
|
return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
|
|
1871
1779
|
}
|
|
@@ -1905,6 +1813,26 @@ export class CommandRouter {
|
|
|
1905
1813
|
this.configCardIds.delete(oldest);
|
|
1906
1814
|
}
|
|
1907
1815
|
}
|
|
1816
|
+
/**
|
|
1817
|
+
* 把一次字段变更交给当前 agent 的 config builder(处理 provider→model 这类
|
|
1818
|
+
* 依赖关系),逐条写进草稿,再把草稿刷到所有配置卡。
|
|
1819
|
+
* `config.set`(下拉)与 `config.input`(输入框提交)共用同一套应用逻辑。
|
|
1820
|
+
*/
|
|
1821
|
+
async applyConfigFieldChange(key, newValue, ctx) {
|
|
1822
|
+
try {
|
|
1823
|
+
this.ensurePendingConfig();
|
|
1824
|
+
const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
|
|
1825
|
+
const configBuilder = getConfigBuilder(defaultAgent);
|
|
1826
|
+
const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
|
|
1827
|
+
for (const patch of patches) {
|
|
1828
|
+
this.setNestedValue(this.pendingConfig, patch.key, patch.value);
|
|
1829
|
+
}
|
|
1830
|
+
await this.refreshConfigCards(ctx);
|
|
1831
|
+
}
|
|
1832
|
+
catch (err) {
|
|
1833
|
+
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1908
1836
|
/**
|
|
1909
1837
|
* 把当前草稿刷到所有已知 config 卡片上(含本次点击的那张)。
|
|
1910
1838
|
*
|
|
@@ -1928,12 +1856,12 @@ export class CommandRouter {
|
|
|
1928
1856
|
}
|
|
1929
1857
|
}
|
|
1930
1858
|
/**
|
|
1931
|
-
* 串行化 config.* 卡片回调(2026-07-04
|
|
1859
|
+
* 串行化 config.* 卡片回调(2026-07-04 修复连击卡死 bug)。
|
|
1932
1860
|
*
|
|
1933
1861
|
* CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,不进 bridge 的
|
|
1934
|
-
*
|
|
1862
|
+
* 串行队列;多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
|
|
1935
1863
|
* `updateCardInPlace` → 两个 patch 请求乱序到达飞书,后到达的 patch(点击 1
|
|
1936
|
-
* 的旧卡片)覆盖先到达的 patch(点击 2
|
|
1864
|
+
* 的旧卡片)覆盖先到达的 patch(点击 2 的新卡片),用户看到控件卡死。
|
|
1937
1865
|
*
|
|
1938
1866
|
* 此方法把所有 config.* 动作串成一个 Promise chain:每个动作等前一个动作
|
|
1939
1867
|
* 的 `updateCardInPlace` 完全 settle 后再读 `pendingConfig`。
|
|
@@ -1956,28 +1884,6 @@ export class CommandRouter {
|
|
|
1956
1884
|
/** config.* 卡片动作分发(public:测试直接调用,替代 as unknown as)。 */
|
|
1957
1885
|
async dispatchConfigAction(value, ctx) {
|
|
1958
1886
|
switch (value.cmd) {
|
|
1959
|
-
case 'config.toggle': {
|
|
1960
|
-
// 通用 boolean 翻转:目前无卡片字段使用(output.show* 已移除),
|
|
1961
|
-
// 保留供未来 boolean 配置项复用;configActionQueue 串行化覆盖连击。
|
|
1962
|
-
const key = value.key;
|
|
1963
|
-
if (!key) {
|
|
1964
|
-
await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
|
|
1965
|
-
return;
|
|
1966
|
-
}
|
|
1967
|
-
this.ensurePendingConfig();
|
|
1968
|
-
const current = getConfigValue(this.pendingConfig, key);
|
|
1969
|
-
// 布尔字段未设置时视为 false,而非报错(如布尔配置项尚未写入 config)
|
|
1970
|
-
const boolVal = typeof current === 'boolean' ? current : false;
|
|
1971
|
-
try {
|
|
1972
|
-
// 只改暂存区,不写盘;原地更新卡片而非发送新卡(2026-07-04)
|
|
1973
|
-
this.setNestedValue(this.pendingConfig, key, !boolVal);
|
|
1974
|
-
await this.refreshConfigCards(ctx);
|
|
1975
|
-
}
|
|
1976
|
-
catch (err) {
|
|
1977
|
-
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
1978
|
-
}
|
|
1979
|
-
return;
|
|
1980
|
-
}
|
|
1981
1887
|
case 'config.set': {
|
|
1982
1888
|
// Set config value from select dropdown (option from CardKit 2.0)
|
|
1983
1889
|
const key = value.key;
|
|
@@ -1986,20 +1892,7 @@ export class CommandRouter {
|
|
|
1986
1892
|
await this.bridge.sendResult({ text: '缺少参数' }, ctx);
|
|
1987
1893
|
return;
|
|
1988
1894
|
}
|
|
1989
|
-
|
|
1990
|
-
this.ensurePendingConfig();
|
|
1991
|
-
// 使用 agent config builder 处理字段变更(处理依赖关系如 provider→model)
|
|
1992
|
-
const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
|
|
1993
|
-
const configBuilder = getConfigBuilder(defaultAgent);
|
|
1994
|
-
const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
|
|
1995
|
-
for (const patch of patches) {
|
|
1996
|
-
this.setNestedValue(this.pendingConfig, patch.key, patch.value);
|
|
1997
|
-
}
|
|
1998
|
-
await this.refreshConfigCards(ctx);
|
|
1999
|
-
}
|
|
2000
|
-
catch (err) {
|
|
2001
|
-
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
2002
|
-
}
|
|
1895
|
+
await this.applyConfigFieldChange(key, newValue, ctx);
|
|
2003
1896
|
return;
|
|
2004
1897
|
}
|
|
2005
1898
|
case 'config.input': {
|
|
@@ -2017,20 +1910,7 @@ export class CommandRouter {
|
|
|
2017
1910
|
await this.bridge.sendResult({ text: '缺少输入值' }, ctx);
|
|
2018
1911
|
return;
|
|
2019
1912
|
}
|
|
2020
|
-
|
|
2021
|
-
this.ensurePendingConfig();
|
|
2022
|
-
// 使用 agent config builder 处理字段变更
|
|
2023
|
-
const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
|
|
2024
|
-
const configBuilder = getConfigBuilder(defaultAgent);
|
|
2025
|
-
const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
|
|
2026
|
-
for (const patch of patches) {
|
|
2027
|
-
this.setNestedValue(this.pendingConfig, patch.key, patch.value);
|
|
2028
|
-
}
|
|
2029
|
-
await this.refreshConfigCards(ctx);
|
|
2030
|
-
}
|
|
2031
|
-
catch (err) {
|
|
2032
|
-
await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
|
|
2033
|
-
}
|
|
1913
|
+
await this.applyConfigFieldChange(key, newValue, ctx);
|
|
2034
1914
|
return;
|
|
2035
1915
|
}
|
|
2036
1916
|
case 'config.save': {
|
|
@@ -2116,18 +1996,9 @@ export class CommandRouter {
|
|
|
2116
1996
|
this.bridge.setIdleTimeout(this.idleTimeoutMs);
|
|
2117
1997
|
// 2026-07-13: 检测是否有任何 agent 配置变更或 defaultAgent 切换
|
|
2118
1998
|
const hasDefaultAgentChange = oldDefaultAgent !== this.config.defaultAgent;
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
k.startsWith('codex.') ||
|
|
2123
|
-
k.startsWith('opencode.') ||
|
|
2124
|
-
k.startsWith('kimi.') ||
|
|
2125
|
-
k === 'defaultAgent' ||
|
|
2126
|
-
k === 'claude' ||
|
|
2127
|
-
k === 'pi' ||
|
|
2128
|
-
k === 'codex' ||
|
|
2129
|
-
k === 'opencode' ||
|
|
2130
|
-
k === 'kimi');
|
|
1999
|
+
const changedKeys = Object.keys(updates);
|
|
2000
|
+
const hasAgentConfigChange = changedKeys.includes('defaultAgent') ||
|
|
2001
|
+
AGENT_KINDS.some((kind) => touchesAgentConfig(changedKeys, kind));
|
|
2131
2002
|
// 当 defaultAgent 改变时,清除旧 agent 的 sessionId,
|
|
2132
2003
|
// 同时清除新 agent 的 sessionId(等效于 /new 命令),
|
|
2133
2004
|
// 并支持 session 恢复 - 切换回来时如果条件满足则恢复之前的 session。
|
|
@@ -2176,43 +2047,30 @@ export class CommandRouter {
|
|
|
2176
2047
|
switchSessionId = '';
|
|
2177
2048
|
}
|
|
2178
2049
|
}
|
|
2179
|
-
// DSH preset
|
|
2180
|
-
//
|
|
2181
|
-
// 旧 sessionId
|
|
2182
|
-
//
|
|
2183
|
-
|
|
2184
|
-
//
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
if (oldSessionId) {
|
|
2189
|
-
this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
|
|
2190
|
-
this.sessionStore.clearSessionId(ctx.userId, 'dsh');
|
|
2191
|
-
switchNotice = `DSH preset 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
|
|
2192
|
-
}
|
|
2193
|
-
else {
|
|
2194
|
-
switchNotice = `DSH preset 已变更,下次消息将新建会话`;
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
// DSH host 变更 = 换服务:旧 host 的 sessionId 发往新 host 是错的。
|
|
2198
|
-
// 视为 session 边界,仿 preset 分支停车 + 清空当前 sessionId,下次 run 新建会话。
|
|
2199
|
-
const hasDshHostChange = Object.keys(updates).some((k) => k === 'agents.dsh.host');
|
|
2200
|
-
if (hasDshHostChange && this.config.defaultAgent === 'dsh' && !switchNotice) {
|
|
2050
|
+
// DSH 的两个「会话边界」变更共用一套处理:preset 在 session 创建时固定,
|
|
2051
|
+
// 中途切换会被服务端拒绝(agent-preset-conflict);host 变更等于换服务,
|
|
2052
|
+
// 旧 host 的 sessionId 发往新 host 是错的。两者都把旧 sessionId 停到
|
|
2053
|
+
// previousSessions 恢复槽(可 /resume 校验后恢复)并清空当前值,下次 run
|
|
2054
|
+
// 走 session.create 新建。已发生 agent 切换时(switchNotice 已设置)跳过,
|
|
2055
|
+
// 避免覆盖切换 notice——切换分支对 dsh 的 session 已做恢复/清空处理。
|
|
2056
|
+
const parkDshSessionBoundary = (changed, label) => {
|
|
2057
|
+
if (!changed || this.config.defaultAgent !== 'dsh' || switchNotice)
|
|
2058
|
+
return;
|
|
2201
2059
|
const oldSessionId = this.sessionStore.getSessionId(ctx.userId, 'dsh');
|
|
2202
2060
|
if (oldSessionId) {
|
|
2203
2061
|
this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
|
|
2204
2062
|
this.sessionStore.clearSessionId(ctx.userId, 'dsh');
|
|
2205
|
-
switchNotice = `DSH
|
|
2063
|
+
switchNotice = `DSH ${label} 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
|
|
2206
2064
|
}
|
|
2207
2065
|
else {
|
|
2208
|
-
switchNotice = `DSH
|
|
2066
|
+
switchNotice = `DSH ${label} 已变更,下次消息将新建会话`;
|
|
2209
2067
|
}
|
|
2210
|
-
}
|
|
2068
|
+
};
|
|
2069
|
+
parkDshSessionBoundary(changedKeys.includes('agents.dsh.agentPreset'), 'preset');
|
|
2070
|
+
parkDshSessionBoundary(changedKeys.includes('agents.dsh.host'), 'host');
|
|
2211
2071
|
// 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
|
|
2212
2072
|
const currentAgent = this.config.defaultAgent;
|
|
2213
|
-
|
|
2214
|
-
const hasCurrentAgentUpdate = Object.keys(updates).some((k) => k === currentAgentConfigKey || k.startsWith(`${currentAgentConfigKey}.`));
|
|
2215
|
-
if (hasCurrentAgentUpdate) {
|
|
2073
|
+
if (touchesAgentsSubtree(changedKeys, currentAgent)) {
|
|
2216
2074
|
this.config = syncAgentChoices(this.config, currentAgent);
|
|
2217
2075
|
// agentChoices 同步写盘必须走原子写(tmp+rename),禁裸同步写盘
|
|
2218
2076
|
atomicWrite(this.configPath, YAML.stringify(this.config));
|
|
@@ -2224,19 +2082,8 @@ export class CommandRouter {
|
|
|
2224
2082
|
// duck method `updateApprovalMode` (codex thread/settings/update,
|
|
2225
2083
|
// kimi/opencode session/set_mode) so they take effect for subsequent
|
|
2226
2084
|
// turns without waiting for the runner to be recreated.
|
|
2227
|
-
const hasApprovalConfigChange =
|
|
2228
|
-
|
|
2229
|
-
k === 'codex' ||
|
|
2230
|
-
k.startsWith('codex.') ||
|
|
2231
|
-
k === 'agents.kimi' ||
|
|
2232
|
-
k.startsWith('agents.kimi.') ||
|
|
2233
|
-
k === 'kimi' ||
|
|
2234
|
-
k.startsWith('kimi.') ||
|
|
2235
|
-
k === 'agents.opencode' ||
|
|
2236
|
-
k.startsWith('agents.opencode.') ||
|
|
2237
|
-
k === 'opencode' ||
|
|
2238
|
-
k.startsWith('opencode.') ||
|
|
2239
|
-
k === 'agents.dsh.permissionPreset');
|
|
2085
|
+
const hasApprovalConfigChange = ['codex', 'kimi', 'opencode'].some((kind) => touchesAgentConfig(changedKeys, kind)) ||
|
|
2086
|
+
changedKeys.includes('agents.dsh.permissionPreset');
|
|
2240
2087
|
if (hasApprovalConfigChange) {
|
|
2241
2088
|
this.bridge.syncActiveApprovalModes();
|
|
2242
2089
|
}
|
|
@@ -2449,14 +2296,14 @@ export class CommandRouter {
|
|
|
2449
2296
|
cmdStatus(ctx) {
|
|
2450
2297
|
const entry = this.sessionStore.get(ctx.userId);
|
|
2451
2298
|
const cwd = entry?.cwd ?? '(未设置)';
|
|
2452
|
-
const sessionId = entry?.sessions
|
|
2299
|
+
const sessionId = entry?.sessions.get(this.config.defaultAgent) ?? '(无)';
|
|
2453
2300
|
// 查 bridge.activeRuns 而不是默认 runner.isRunning —— 因为 per-workspace
|
|
2454
2301
|
// runner 启动的 run 不会反映到默认 runner 状态。
|
|
2455
2302
|
const running = cwd !== '(未设置)' && this.bridge.isBusyFor(cwd);
|
|
2456
2303
|
const logger = getLogger();
|
|
2457
2304
|
const pid = process.pid;
|
|
2458
2305
|
const configDir = getConfigDir();
|
|
2459
|
-
const logFile =
|
|
2306
|
+
const logFile = logger.getCurrentLogFile();
|
|
2460
2307
|
// 构建 agent 状态信息
|
|
2461
2308
|
const agentLines = [];
|
|
2462
2309
|
if (cwd !== '(未设置)') {
|
|
@@ -2629,38 +2476,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2629
2476
|
const currentCwd = this.sessionStore.getCwd(ctx.userId) ?? process.cwd();
|
|
2630
2477
|
const expanded = target.startsWith('~') ? path.join(os.homedir(), target.slice(1)) : target;
|
|
2631
2478
|
const resolved = path.resolve(currentCwd, expanded);
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
try {
|
|
2639
|
-
stat = fs.statSync(resolved);
|
|
2640
|
-
}
|
|
2641
|
-
catch {
|
|
2642
|
-
return { text: `路径无效: ${resolved}` };
|
|
2643
|
-
}
|
|
2644
|
-
if (!stat.isDirectory()) {
|
|
2645
|
-
return { text: `不是目录: ${resolved}` };
|
|
2646
|
-
}
|
|
2647
|
-
// Canonicalize via realpath so it matches the symlink-resolved cwd that
|
|
2648
|
-
// Claude writes into JSONL (e.g. `/tmp` → `/private/tmp` on macOS).
|
|
2649
|
-
// Without this, a hand-typed unresolved path never matches any JSONL's
|
|
2650
|
-
// cwd field and the user's `/cd /tmp/foo` never finds their sessions.
|
|
2651
|
-
// TOCTOU: the directory may vanish between stat and realpath.
|
|
2652
|
-
// Reject gracefully instead of letting realpathSync throw into the queue
|
|
2653
|
-
// task's .catch (which only logs — the user gets no feedback). Mirrors
|
|
2654
|
-
// handleLsSwitch's realpath guard so /cd and ls.switch behave identically.
|
|
2655
|
-
let canonical;
|
|
2656
|
-
try {
|
|
2657
|
-
canonical = fs.realpathSync(resolved);
|
|
2658
|
-
}
|
|
2659
|
-
catch {
|
|
2479
|
+
const resolution = resolveDirectory(resolved);
|
|
2480
|
+
if (!resolution.ok) {
|
|
2481
|
+
if (resolution.reason === 'missing')
|
|
2482
|
+
return { text: `路径不存在: ${resolved}` };
|
|
2483
|
+
if (resolution.reason === 'not_directory')
|
|
2484
|
+
return { text: `不是目录: ${resolved}` };
|
|
2660
2485
|
return { text: `路径无效: ${resolved}` };
|
|
2661
2486
|
}
|
|
2662
2487
|
// Switch cwd with auto-resume and user feedback (shared with /ws use and ls.switch)
|
|
2663
|
-
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
2488
|
+
return this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
|
|
2664
2489
|
}
|
|
2665
2490
|
readSessionDisplayState(sessionId, cwd, opts) {
|
|
2666
2491
|
// Use the appropriate session reader based on agentKind
|
|
@@ -2871,7 +2696,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2871
2696
|
}
|
|
2872
2697
|
return actions;
|
|
2873
2698
|
}
|
|
2874
|
-
static LS_PAGE_SIZE = 30;
|
|
2875
2699
|
/**
|
|
2876
2700
|
* `/ls` 实现(public:测试直接调用,替代 as unknown as)。
|
|
2877
2701
|
*
|
|
@@ -2884,246 +2708,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2884
2708
|
* 省略/空 = 无筛选。
|
|
2885
2709
|
*/
|
|
2886
2710
|
cmdLs(args, ctx, offset = 0, rootDir, q) {
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
// - [] : list current cwd
|
|
2894
|
-
// - [dirName] : list subdirectory if it exists
|
|
2895
|
-
let targetDir = cwd;
|
|
2896
|
-
if (args[0]) {
|
|
2897
|
-
// Expand tilde to home directory (path.resolve does not handle ~)
|
|
2898
|
-
if (args[0].startsWith('~')) {
|
|
2899
|
-
args[0] = path.join(os.homedir(), args[0].slice(1));
|
|
2900
|
-
}
|
|
2901
|
-
// path.resolve (not path.join) so absolute args[0] is honored as-is
|
|
2902
|
-
// instead of being concatenated onto cwd (which broke /ls card nav).
|
|
2903
|
-
const potentialDir = path.resolve(cwd, args[0]);
|
|
2904
|
-
if (!fs.existsSync(potentialDir)) {
|
|
2905
|
-
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
2906
|
-
}
|
|
2907
|
-
let isDir;
|
|
2908
|
-
try {
|
|
2909
|
-
isDir = fs.statSync(potentialDir).isDirectory();
|
|
2910
|
-
}
|
|
2911
|
-
catch {
|
|
2912
|
-
// TOCTOU:existsSync 与 statSync 之间目标被删
|
|
2913
|
-
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
2914
|
-
}
|
|
2915
|
-
if (!isDir) {
|
|
2916
|
-
// 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
|
|
2917
|
-
// 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
|
|
2918
|
-
return this.buildFileCard(potentialDir);
|
|
2919
|
-
}
|
|
2920
|
-
targetDir = potentialDir;
|
|
2921
|
-
}
|
|
2922
|
-
// 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
|
|
2923
|
-
// 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
|
|
2924
|
-
// 列出的目录,因此「返回」永远不会把用户丢回工作目录。
|
|
2925
|
-
const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
|
|
2926
|
-
try {
|
|
2927
|
-
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
2928
|
-
// 逐条目分类。`Dirent.isFile()/isDirectory()` 对符号链接恒 false(旧实现
|
|
2929
|
-
// 因此完全不列链接);必须 stat 解析目标。单条目 stat 失败(悬空链接、
|
|
2930
|
-
// readdir 与 stat 之间被删、无权限)只让该条目降级为「大小未知」,不能
|
|
2931
|
-
// 让整次列目录失败——一个坏邻居毁掉整张卡是旧实现的第二宗罪。
|
|
2932
|
-
const dirs = [];
|
|
2933
|
-
const files = [];
|
|
2934
|
-
for (const entry of entries) {
|
|
2935
|
-
let isDir = entry.isDirectory();
|
|
2936
|
-
let size;
|
|
2937
|
-
if (!isDir) {
|
|
2938
|
-
try {
|
|
2939
|
-
const st = fs.statSync(path.join(targetDir, entry.name));
|
|
2940
|
-
isDir = st.isDirectory();
|
|
2941
|
-
if (!isDir)
|
|
2942
|
-
size = st.size;
|
|
2943
|
-
}
|
|
2944
|
-
catch {
|
|
2945
|
-
// 目标拿不到:按文件列出,size 留空
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
if (isDir)
|
|
2949
|
-
dirs.push(entry.name);
|
|
2950
|
-
else
|
|
2951
|
-
files.push({ name: entry.name, size });
|
|
2952
|
-
}
|
|
2953
|
-
dirs.sort((a, b) => a.localeCompare(b));
|
|
2954
|
-
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
2955
|
-
// Merge dirs and files for pagination, preserving type info
|
|
2956
|
-
const allItems = [
|
|
2957
|
-
...dirs.map((name) => ({ name, isDir: true })),
|
|
2958
|
-
...files.map((f) => ({ name: f.name, isDir: false, size: f.size })),
|
|
2959
|
-
];
|
|
2960
|
-
// 关键词筛选:只匹配当前层条目名(不递归子目录)。大小写不敏感的子串包含
|
|
2961
|
-
// ——禁止 new RegExp(q)(用户搜 `foo(` 会直接抛)。插在排序之后、分页之前。
|
|
2962
|
-
const ql = filter?.toLowerCase();
|
|
2963
|
-
const visibleItems = ql
|
|
2964
|
-
? allItems.filter((i) => i.name.toLowerCase().includes(ql))
|
|
2965
|
-
: allItems;
|
|
2966
|
-
const visibleDirCount = visibleItems.reduce((n, i) => n + (i.isDir ? 1 : 0), 0);
|
|
2967
|
-
const visibleFileCount = visibleItems.length - visibleDirCount;
|
|
2968
|
-
// Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
|
|
2969
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(visibleItems, offset, CommandRouter.LS_PAGE_SIZE);
|
|
2970
|
-
// Check if we need to show parent directory button
|
|
2971
|
-
const parentDir = path.dirname(targetDir);
|
|
2972
|
-
const hasParent = parentDir !== targetDir;
|
|
2973
|
-
// 「切换」相对 cwd(把工作目录切到当前浏览目录)
|
|
2974
|
-
const isSubdir = targetDir !== cwd;
|
|
2975
|
-
// 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
|
|
2976
|
-
const canReturn = targetDir !== browseRoot;
|
|
2977
|
-
// Build header with parent button
|
|
2978
|
-
const headerElements = [];
|
|
2979
|
-
if (hasParent) {
|
|
2980
|
-
headerElements.push(cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: browseRoot }, { type: 'default', size: 'small' }));
|
|
2981
|
-
}
|
|
2982
|
-
// Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
|
|
2983
|
-
if (canReturn) {
|
|
2984
|
-
headerElements.push(cardButton('返回', { cmd: 'ls.browse', path: browseRoot, root: browseRoot }, { type: 'default', size: 'small' }));
|
|
2985
|
-
}
|
|
2986
|
-
headerElements.push(cardButton('刷新', {
|
|
2987
|
-
cmd: 'ls.refresh',
|
|
2988
|
-
path: targetDir,
|
|
2989
|
-
offset: safeOffset,
|
|
2990
|
-
root: browseRoot,
|
|
2991
|
-
...(filter ? { q: filter } : {}),
|
|
2992
|
-
}, { type: 'default', size: 'small' }));
|
|
2993
|
-
// Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
|
|
2994
|
-
if (isSubdir) {
|
|
2995
|
-
headerElements.push(
|
|
2996
|
-
// 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
|
|
2997
|
-
// 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
|
|
2998
|
-
cardButton('切换', { cmd: 'ls.switch', path: targetDir, root: browseRoot }, { type: 'primary', size: 'small' }));
|
|
2999
|
-
}
|
|
3000
|
-
// Helper to create directory button - use targetDir
|
|
3001
|
-
const dirButton = (name) => cardButton(`📁 ${name}`, { cmd: 'ls.browse', path: path.join(targetDir, name), root: browseRoot }, { type: 'default' });
|
|
3002
|
-
// Helper to create file button with size - use targetDir
|
|
3003
|
-
const fileButton = (name, size) => {
|
|
3004
|
-
const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
|
|
3005
|
-
return cardButton(`📄 ${name}${sizeStr}`, { cmd: 'ls.file', path: path.join(targetDir, name) }, { type: 'default', size: 'small' });
|
|
3006
|
-
};
|
|
3007
|
-
// Build each section's elements (no tabs - use section headers)
|
|
3008
|
-
// Note: div+elements is not supported in CardKit 2.0, use column_set instead
|
|
3009
|
-
const elements = [];
|
|
3010
|
-
// Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
|
|
3011
|
-
// 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。筛选态一律用
|
|
3012
|
-
// 过滤后的计数,否则标题说 12 项、列表只有 3 项,卡片自相矛盾。
|
|
3013
|
-
const status = `\n共 ${visibleDirCount} 目录, ${visibleFileCount} 文件`;
|
|
3014
|
-
// Header info + navigation buttons - show targetDir in header
|
|
3015
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
|
|
3016
|
-
elements.push({
|
|
3017
|
-
tag: 'column_set',
|
|
3018
|
-
columns: headerElements.map((e) => ({ tag: 'column', width: 'auto', elements: [e] })),
|
|
3019
|
-
});
|
|
3020
|
-
elements.push({ tag: 'hr' });
|
|
3021
|
-
// 搜索行:头部导航按钮之下、内容区之上(主导航在上、过滤紧随其后、内容区
|
|
3022
|
-
// 在下)。path/root 必须进 callback value,否则提交后 handler 不知道刷哪。
|
|
3023
|
-
elements.push(searchBar({
|
|
3024
|
-
cmd: 'ls.filter',
|
|
3025
|
-
placeholder: '搜索本层目录/文件名,输完回车',
|
|
3026
|
-
currentQuery: filter,
|
|
3027
|
-
extra: { path: targetDir, root: browseRoot },
|
|
3028
|
-
}));
|
|
3029
|
-
if (filter) {
|
|
3030
|
-
elements.push({
|
|
3031
|
-
tag: 'div',
|
|
3032
|
-
text: {
|
|
3033
|
-
tag: 'lark_md',
|
|
3034
|
-
content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 项(点「清除筛选」取消)`,
|
|
3035
|
-
},
|
|
3036
|
-
});
|
|
3037
|
-
}
|
|
3038
|
-
// Section 1 + 2: Directories / Files (show only items on current page)
|
|
3039
|
-
const pageDirs = pageItems.filter((i) => i.isDir);
|
|
3040
|
-
const pageFiles = pageItems.filter((i) => !i.isDir);
|
|
3041
|
-
if (filter && visibleItems.length === 0) {
|
|
3042
|
-
// 筛选零命中:两个区标题都不渲染(两行空区标题只是噪音),搜索行仍在,
|
|
3043
|
-
// 用户可直接改词再回车,或点「清除筛选」。
|
|
3044
|
-
elements.push({
|
|
3045
|
-
tag: 'div',
|
|
3046
|
-
text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
|
|
3047
|
-
});
|
|
3048
|
-
}
|
|
3049
|
-
else {
|
|
3050
|
-
elements.push({
|
|
3051
|
-
tag: 'div',
|
|
3052
|
-
text: { tag: 'lark_md', content: `**📂 目录 (${visibleDirCount})**` },
|
|
3053
|
-
});
|
|
3054
|
-
if (pageDirs.length > 0) {
|
|
3055
|
-
for (const d of pageDirs) {
|
|
3056
|
-
elements.push({
|
|
3057
|
-
tag: 'column_set',
|
|
3058
|
-
flex_mode: 'none',
|
|
3059
|
-
columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
|
|
3060
|
-
});
|
|
3061
|
-
}
|
|
3062
|
-
}
|
|
3063
|
-
else if (visibleDirCount === 0) {
|
|
3064
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
|
|
3065
|
-
}
|
|
3066
|
-
else {
|
|
3067
|
-
elements.push({
|
|
3068
|
-
tag: 'div',
|
|
3069
|
-
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无目录)` },
|
|
3070
|
-
});
|
|
3071
|
-
}
|
|
3072
|
-
elements.push({ tag: 'hr' });
|
|
3073
|
-
// Section 2: Files
|
|
3074
|
-
elements.push({
|
|
3075
|
-
tag: 'div',
|
|
3076
|
-
text: { tag: 'lark_md', content: `**📄 文件 (${visibleFileCount})**` },
|
|
3077
|
-
});
|
|
3078
|
-
if (pageFiles.length > 0) {
|
|
3079
|
-
for (const f of pageFiles) {
|
|
3080
|
-
elements.push({
|
|
3081
|
-
tag: 'column_set',
|
|
3082
|
-
flex_mode: 'none',
|
|
3083
|
-
columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
|
|
3084
|
-
});
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3087
|
-
else if (visibleFileCount === 0) {
|
|
3088
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
|
|
3089
|
-
}
|
|
3090
|
-
else {
|
|
3091
|
-
elements.push({
|
|
3092
|
-
tag: 'div',
|
|
3093
|
-
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无文件)` },
|
|
3094
|
-
});
|
|
3095
|
-
}
|
|
3096
|
-
}
|
|
3097
|
-
// Pagination bar (only shown when there are more items than PAGE_SIZE)
|
|
3098
|
-
if (hasPagination) {
|
|
3099
|
-
const bar = paginationBar({
|
|
3100
|
-
cmd: 'ls.page',
|
|
3101
|
-
offset: safeOffset,
|
|
3102
|
-
pageSize: CommandRouter.LS_PAGE_SIZE,
|
|
3103
|
-
total: totalCount,
|
|
3104
|
-
extra: { path: targetDir, root: browseRoot, ...(filter ? { q: filter } : {}) },
|
|
3105
|
-
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
|
|
3106
|
-
});
|
|
3107
|
-
elements.push({ tag: 'hr' });
|
|
3108
|
-
elements.push(...bar);
|
|
3109
|
-
}
|
|
3110
|
-
return {
|
|
3111
|
-
card: {
|
|
3112
|
-
schema: '2.0',
|
|
3113
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
3114
|
-
header: {
|
|
3115
|
-
title: { tag: 'plain_text', content: `📁 ${path.basename(targetDir)}` },
|
|
3116
|
-
template: 'blue',
|
|
3117
|
-
},
|
|
3118
|
-
body: {
|
|
3119
|
-
elements,
|
|
3120
|
-
},
|
|
3121
|
-
},
|
|
3122
|
-
};
|
|
3123
|
-
}
|
|
3124
|
-
catch (err) {
|
|
3125
|
-
return { text: `读取目录失败: ${err.message}` };
|
|
3126
|
-
}
|
|
2711
|
+
return buildLsCommand(args, ctx, {
|
|
2712
|
+
cwd: this.sessionStore.getCwd(ctx.userId),
|
|
2713
|
+
offset,
|
|
2714
|
+
rootDir,
|
|
2715
|
+
q,
|
|
2716
|
+
});
|
|
3127
2717
|
}
|
|
3128
2718
|
/**
|
|
3129
2719
|
* `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
|
|
@@ -3149,283 +2739,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3149
2739
|
}
|
|
3150
2740
|
return this.deliverFileToFeishu(path.resolve(input), ctx);
|
|
3151
2741
|
}
|
|
3152
|
-
/**
|
|
3153
|
-
* 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
|
|
3154
|
-
*
|
|
3155
|
-
* `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
|
|
3156
|
-
* 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
|
|
3157
|
-
*/
|
|
3158
|
-
buildFileCard(filePath) {
|
|
3159
|
-
let stat;
|
|
3160
|
-
try {
|
|
3161
|
-
stat = fs.statSync(filePath);
|
|
3162
|
-
}
|
|
3163
|
-
catch {
|
|
3164
|
-
stat = undefined;
|
|
3165
|
-
}
|
|
3166
|
-
const sizeText = stat ? formatBytes(stat.size) : '未知';
|
|
3167
|
-
const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
|
|
3168
|
-
const parentDir = path.dirname(filePath);
|
|
3169
|
-
const buttons = [
|
|
3170
|
-
cardButton('📎 下载', { cmd: 'ls.file', path: filePath }, { type: 'primary', size: 'small' }),
|
|
3171
|
-
];
|
|
3172
|
-
if (parentDir !== filePath) {
|
|
3173
|
-
buttons.push(
|
|
3174
|
-
// 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 工作目录
|
|
3175
|
-
cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: parentDir }, { type: 'default', size: 'small' }));
|
|
3176
|
-
}
|
|
3177
|
-
return {
|
|
3178
|
-
card: {
|
|
3179
|
-
schema: '2.0',
|
|
3180
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
3181
|
-
header: {
|
|
3182
|
-
title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
|
|
3183
|
-
template: 'blue',
|
|
3184
|
-
},
|
|
3185
|
-
body: {
|
|
3186
|
-
elements: [
|
|
3187
|
-
{
|
|
3188
|
-
tag: 'div',
|
|
3189
|
-
text: {
|
|
3190
|
-
tag: 'lark_md',
|
|
3191
|
-
content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
|
|
3192
|
-
},
|
|
3193
|
-
},
|
|
3194
|
-
{
|
|
3195
|
-
tag: 'column_set',
|
|
3196
|
-
columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
|
|
3197
|
-
},
|
|
3198
|
-
{ tag: 'hr' },
|
|
3199
|
-
{
|
|
3200
|
-
tag: 'div',
|
|
3201
|
-
text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
|
|
3202
|
-
},
|
|
3203
|
-
],
|
|
3204
|
-
},
|
|
3205
|
-
},
|
|
3206
|
-
};
|
|
3207
|
-
}
|
|
3208
|
-
/**
|
|
3209
|
-
* `/ws` 实现(public:测试直接调用,替代 as unknown as,与 cmdLs 同款)。
|
|
3210
|
-
*
|
|
3211
|
-
* @param offset list 分支的分页起点(条目数)
|
|
3212
|
-
* @param q list 分支的关键词筛选:name 或完整 path 命中即保留,大小写
|
|
3213
|
-
* 不敏感。省略/空 = 无筛选。
|
|
3214
|
-
*/
|
|
3215
2742
|
cmdWs(args, ctx, offset = 0, q) {
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
}
|
|
3228
|
-
case 'use': {
|
|
3229
|
-
const name = args[1];
|
|
3230
|
-
if (!name)
|
|
3231
|
-
return { text: '用法: /ws use <name>' };
|
|
3232
|
-
const wsPath = this.workspaceStore.get(name);
|
|
3233
|
-
if (!wsPath)
|
|
3234
|
-
return { text: `workspace "${name}" 不存在` };
|
|
3235
|
-
if (!fs.existsSync(wsPath))
|
|
3236
|
-
return { text: `路径不存在: ${wsPath}` };
|
|
3237
|
-
// 目录校验与 /cd、ls.switch 对齐:目录被删后同路径出现同名文件(或
|
|
3238
|
-
// workspace.json 被手改)时 realpath 照样成功——放行会让下一次 run
|
|
3239
|
-
// 以文件为 cwd spawn(ENOTDIR)。
|
|
3240
|
-
if (!isDirectorySafe(wsPath))
|
|
3241
|
-
return { text: `不是目录: ${wsPath}` };
|
|
3242
|
-
// Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
|
|
3243
|
-
// TOCTOU guard: mirror cmdCd — if the path vanishes
|
|
3244
|
-
// between existsSync and realpath, reply gracefully instead of
|
|
3245
|
-
// throwing into the queue task's silent .catch.
|
|
3246
|
-
let canonical;
|
|
3247
|
-
try {
|
|
3248
|
-
canonical = fs.realpathSync(wsPath);
|
|
3249
|
-
}
|
|
3250
|
-
catch {
|
|
3251
|
-
return { text: `路径无效: ${wsPath}` };
|
|
3252
|
-
}
|
|
3253
|
-
// Record usage timestamp AFTER successful path validation
|
|
3254
|
-
this.workspaceStore.touch(name);
|
|
3255
|
-
// Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
|
|
3256
|
-
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
3257
|
-
}
|
|
3258
|
-
case 'remove': {
|
|
3259
|
-
const name = args[1];
|
|
3260
|
-
if (!name)
|
|
3261
|
-
return { text: '用法: /ws remove <name>' };
|
|
3262
|
-
if (!this.workspaceStore.has(name))
|
|
3263
|
-
return { text: `workspace "${name}" 不存在` };
|
|
3264
|
-
this.workspaceStore.remove(name);
|
|
3265
|
-
return { text: `已删除 workspace "${name}"` };
|
|
3266
|
-
}
|
|
3267
|
-
case 'list':
|
|
3268
|
-
default: {
|
|
3269
|
-
const filter = q?.trim() || undefined;
|
|
3270
|
-
const entries = this.workspaceStore.list();
|
|
3271
|
-
const currentCwd = this.sessionStore.getCwd(ctx.userId);
|
|
3272
|
-
const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
|
|
3273
|
-
// Sort entries by the current preference (view concern, not stored)
|
|
3274
|
-
if (sortMode === 'recent') {
|
|
3275
|
-
// Most recently used first; same lastUsedAt → alphabetical by name (deterministic)
|
|
3276
|
-
entries.sort((a, b) => {
|
|
3277
|
-
if (b.lastUsedAt !== a.lastUsedAt)
|
|
3278
|
-
return b.lastUsedAt - a.lastUsedAt;
|
|
3279
|
-
return a.name.localeCompare(b.name);
|
|
3280
|
-
});
|
|
3281
|
-
}
|
|
3282
|
-
else {
|
|
3283
|
-
// Alphabetical by alias name
|
|
3284
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
3285
|
-
}
|
|
3286
|
-
// 关键词筛选:alias name 或完整 path 命中即保留,大小写不敏感的子串包含
|
|
3287
|
-
// (禁止 new RegExp(q)——用户搜 `foo(` 会直接抛)。插在排序之后、分页
|
|
3288
|
-
// 之前:先分页再过滤会出现某页只剩 1 条的残页,且翻页时筛选像是丢了。
|
|
3289
|
-
const ql = filter?.toLowerCase();
|
|
3290
|
-
const visible = ql
|
|
3291
|
-
? entries.filter((e) => e.name.toLowerCase().includes(ql) || e.path.toLowerCase().includes(ql))
|
|
3292
|
-
: entries;
|
|
3293
|
-
// Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
|
|
3294
|
-
// offsets so prev/next always step by WS_PAGE_SIZE.
|
|
3295
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(visible, offset, WS_PAGE_SIZE);
|
|
3296
|
-
// Build body elements: current cwd + cwd list with dividers
|
|
3297
|
-
const bodyElements = [];
|
|
3298
|
-
// Current working directory section
|
|
3299
|
-
bodyElements.push({
|
|
3300
|
-
tag: 'div',
|
|
3301
|
-
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
|
|
3302
|
-
});
|
|
3303
|
-
bodyElements.push({ tag: 'hr' });
|
|
3304
|
-
// 搜索行:「当前工作目录」之下、排序行之上(与 /ls 同一视觉动线:主信息
|
|
3305
|
-
// 在上、过滤紧随其后、列表在下)
|
|
3306
|
-
bodyElements.push(searchBar({
|
|
3307
|
-
cmd: 'ws.filter',
|
|
3308
|
-
placeholder: '搜索名称或路径,输完回车',
|
|
3309
|
-
currentQuery: filter,
|
|
3310
|
-
}));
|
|
3311
|
-
if (filter) {
|
|
3312
|
-
bodyElements.push({
|
|
3313
|
-
tag: 'div',
|
|
3314
|
-
text: {
|
|
3315
|
-
tag: 'lark_md',
|
|
3316
|
-
content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 个 workspace(点「清除筛选」取消)`,
|
|
3317
|
-
},
|
|
3318
|
-
});
|
|
3319
|
-
}
|
|
3320
|
-
if (entries.length === 0 && !filter) {
|
|
3321
|
-
bodyElements.push({
|
|
3322
|
-
tag: 'div',
|
|
3323
|
-
text: { tag: 'lark_md', content: '没有保存的 workspace' },
|
|
3324
|
-
});
|
|
3325
|
-
}
|
|
3326
|
-
else if (filter && visible.length === 0) {
|
|
3327
|
-
bodyElements.push({
|
|
3328
|
-
tag: 'div',
|
|
3329
|
-
text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
|
|
3330
|
-
});
|
|
3331
|
-
}
|
|
3332
|
-
else {
|
|
3333
|
-
// Sort mode indicator + toggle button: placed above the list so the user
|
|
3334
|
-
// sees "current mode" and "switch to X" before scanning entries.
|
|
3335
|
-
// On narrow (mobile) screens, the label and button must convey both
|
|
3336
|
-
// what-is-active and what-clicking-does to avoid ambiguity.
|
|
3337
|
-
const currentLabel = sortMode === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
|
|
3338
|
-
const switchLabel = sortMode === 'recent' ? '🔤 字母顺序' : '🕐 最近使用';
|
|
3339
|
-
bodyElements.push({ tag: 'hr' });
|
|
3340
|
-
bodyElements.push({
|
|
3341
|
-
tag: 'column_set',
|
|
3342
|
-
columns: [
|
|
3343
|
-
{
|
|
3344
|
-
tag: 'column',
|
|
3345
|
-
width: 'weighted',
|
|
3346
|
-
weight: 1,
|
|
3347
|
-
vertical_align: 'center',
|
|
3348
|
-
elements: [
|
|
3349
|
-
{
|
|
3350
|
-
tag: 'div',
|
|
3351
|
-
text: { tag: 'lark_md', content: `排序:${currentLabel}` },
|
|
3352
|
-
},
|
|
3353
|
-
],
|
|
3354
|
-
},
|
|
3355
|
-
{
|
|
3356
|
-
tag: 'column',
|
|
3357
|
-
width: 'auto',
|
|
3358
|
-
elements: [
|
|
3359
|
-
cardButton(`切换为 ${switchLabel}`, { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) }, { type: 'default', size: 'small' }),
|
|
3360
|
-
],
|
|
3361
|
-
},
|
|
3362
|
-
],
|
|
3363
|
-
});
|
|
3364
|
-
bodyElements.push({ tag: 'hr' });
|
|
3365
|
-
for (const entry of pageEntries) {
|
|
3366
|
-
bodyElements.push({
|
|
3367
|
-
tag: 'div',
|
|
3368
|
-
text: { tag: 'lark_md', content: `**${entry.name}** → \`${entry.path}\`` },
|
|
3369
|
-
});
|
|
3370
|
-
// Use column_set+column for 2.0 (action tag not supported in 2.0)
|
|
3371
|
-
bodyElements.push({
|
|
3372
|
-
tag: 'column_set',
|
|
3373
|
-
columns: [
|
|
3374
|
-
{
|
|
3375
|
-
tag: 'column',
|
|
3376
|
-
width: 'auto',
|
|
3377
|
-
elements: [
|
|
3378
|
-
cardButton('切换', {
|
|
3379
|
-
cmd: 'ws.use',
|
|
3380
|
-
name: entry.name,
|
|
3381
|
-
offset: safeOffset,
|
|
3382
|
-
...(filter ? { q: filter } : {}),
|
|
3383
|
-
}, { type: 'primary', size: 'small' }),
|
|
3384
|
-
],
|
|
3385
|
-
},
|
|
3386
|
-
{
|
|
3387
|
-
tag: 'column',
|
|
3388
|
-
width: 'auto',
|
|
3389
|
-
elements: [
|
|
3390
|
-
cardButton('删除', {
|
|
3391
|
-
cmd: 'ws.remove',
|
|
3392
|
-
name: entry.name,
|
|
3393
|
-
offset: safeOffset,
|
|
3394
|
-
...(filter ? { q: filter } : {}),
|
|
3395
|
-
}, { type: 'danger', size: 'small' }),
|
|
3396
|
-
],
|
|
3397
|
-
},
|
|
3398
|
-
],
|
|
3399
|
-
});
|
|
3400
|
-
bodyElements.push({ tag: 'hr' });
|
|
3401
|
-
}
|
|
3402
|
-
bodyElements.pop();
|
|
3403
|
-
// Pagination bar — separate row below the list (not crowded with sort
|
|
3404
|
-
// toggle; mobile-friendly vertical layout).
|
|
3405
|
-
if (hasPagination) {
|
|
3406
|
-
const bar = paginationBar({
|
|
3407
|
-
cmd: 'ws.page',
|
|
3408
|
-
offset: safeOffset,
|
|
3409
|
-
pageSize: WS_PAGE_SIZE,
|
|
3410
|
-
total: totalCount,
|
|
3411
|
-
extra: filter ? { q: filter } : {},
|
|
3412
|
-
label: `**${currentPage}/${totalPages}**(${totalCount})`,
|
|
3413
|
-
prevText: '⬅',
|
|
3414
|
-
nextText: '➡',
|
|
3415
|
-
});
|
|
3416
|
-
bodyElements.push({ tag: 'hr' });
|
|
3417
|
-
bodyElements.push(...bar);
|
|
3418
|
-
}
|
|
3419
|
-
}
|
|
3420
|
-
const card = {
|
|
3421
|
-
schema: '2.0',
|
|
3422
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
3423
|
-
header: { title: { tag: 'plain_text', content: 'Workspaces' } },
|
|
3424
|
-
body: { elements: bodyElements },
|
|
3425
|
-
};
|
|
3426
|
-
return { card };
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
2743
|
+
return buildWsCommand(args, ctx, offset, q, {
|
|
2744
|
+
getCwd: (userId) => this.sessionStore.getCwd(userId),
|
|
2745
|
+
saveWorkspace: (name, cwd) => this.workspaceStore.save(name, cwd),
|
|
2746
|
+
getWorkspace: (name) => this.workspaceStore.get(name),
|
|
2747
|
+
hasWorkspace: (name) => this.workspaceStore.has(name),
|
|
2748
|
+
removeWorkspace: (name) => this.workspaceStore.remove(name),
|
|
2749
|
+
touchWorkspace: (name) => this.workspaceStore.touch(name),
|
|
2750
|
+
listWorkspaces: () => this.workspaceStore.list(),
|
|
2751
|
+
getSortMode: (userId) => this.wsSortPreference.get(userId) ?? 'recent',
|
|
2752
|
+
switchCwd: (userId, cwd, actionCtx) => this.switchCwdAndNotify(userId, cwd, actionCtx),
|
|
2753
|
+
});
|
|
3429
2754
|
}
|
|
3430
2755
|
/**
|
|
3431
2756
|
* DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
|
|
@@ -3454,286 +2779,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3454
2779
|
return { sessionPreset, currentPreset };
|
|
3455
2780
|
}
|
|
3456
2781
|
cmdResume(args, ctx, offset = 0) {
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
if (args[1]) {
|
|
3469
|
-
if (/^\d+$/.test(args[1])) {
|
|
3470
|
-
limit = parseInt(args[1], 10);
|
|
3471
|
-
}
|
|
3472
|
-
else {
|
|
3473
|
-
sessionIdArg = args[1];
|
|
3474
|
-
}
|
|
3475
|
-
}
|
|
3476
|
-
}
|
|
3477
|
-
else if (/^\d+$/.test(args[0])) {
|
|
3478
|
-
limit = parseInt(args[0], 10);
|
|
3479
|
-
}
|
|
3480
|
-
else {
|
|
3481
|
-
sessionIdArg = args[0];
|
|
3482
|
-
}
|
|
3483
|
-
}
|
|
3484
|
-
// `/resume <sessionId>` — manually set session id (needs an existing cwd to bind to)
|
|
3485
|
-
if (sessionIdArg) {
|
|
3486
|
-
if (!entry || !cwd)
|
|
3487
|
-
return { text: '请先 /cd 设置工作目录' };
|
|
3488
|
-
const reader = agentKind !== this.config.defaultAgent
|
|
3489
|
-
? this.sessionReaderRegistry.get(agentKind)
|
|
3490
|
-
: this.sessionReader;
|
|
3491
|
-
const verifyContent = reader.readSessionContent(sessionIdArg, cwd);
|
|
3492
|
-
// If session doesn't exist, reject the write
|
|
3493
|
-
if (verifyContent.events.length === 0 &&
|
|
3494
|
-
!verifyContent.usage &&
|
|
3495
|
-
!verifyContent.aiTitle &&
|
|
3496
|
-
!verifyContent.recap &&
|
|
3497
|
-
!verifyContent.displayTitle) {
|
|
3498
|
-
return {
|
|
3499
|
-
text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
|
|
3500
|
-
};
|
|
3501
|
-
}
|
|
3502
|
-
// DSH preset 一致性校验(§6 风险 1):session 的 preset 在创建时固定,
|
|
3503
|
-
// 若与当前配置不符,直接复用会让后续 run 报 agent-preset-conflict。
|
|
3504
|
-
// 不静默复用——明确提示用户,由用户决定是否继续(配置里手动改/清 preset)。
|
|
3505
|
-
if (agentKind === 'dsh' && this.config.defaultAgent === 'dsh') {
|
|
3506
|
-
const presetMismatch = this.dshPresetMismatch(reader, sessionIdArg, cwd);
|
|
3507
|
-
if (presetMismatch) {
|
|
3508
|
-
return {
|
|
3509
|
-
text: `⚠️ 该 DSH session 的 preset 是「${presetMismatch.sessionPreset}」,与当前配置「${presetMismatch.currentPreset}」不一致。\n` +
|
|
3510
|
-
`preset 在 session 创建时固定,无法中途切换(DSH 返回 agent-preset-conflict)。\n` +
|
|
3511
|
-
`继续恢复将在下次 run 失败。请在 /config 中把预设模式改回「${presetMismatch.sessionPreset}」后重试,或保持当前配置并发新消息新建会话。`,
|
|
3512
|
-
};
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
// 校验通过后才写入
|
|
3516
|
-
this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
|
|
3517
|
-
const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
|
|
3518
|
-
agentKind,
|
|
3519
|
-
maxEvents: AUTO_RESUME_MAX_EVENTS,
|
|
3520
|
-
});
|
|
3521
|
-
// Action buttons: stop (active) + compact (capable runner, not running) + new session
|
|
3522
|
-
const actions = this.buildResumeActionButtons({
|
|
3523
|
-
isActive,
|
|
3524
|
-
activeRunRunId,
|
|
3525
|
-
cwd,
|
|
3526
|
-
compact: this.canCompactSession(agentKind, cwd)
|
|
3527
|
-
? { sessionId: sessionIdArg, agentKind }
|
|
3528
|
-
: undefined,
|
|
3529
|
-
newSession: true,
|
|
3530
|
-
});
|
|
3531
|
-
const card = buildSessionHistoryCard({
|
|
3532
|
-
sessionId: sessionIdArg,
|
|
3533
|
-
cwd,
|
|
3534
|
-
displayTitle,
|
|
3535
|
-
aiTitle,
|
|
3536
|
-
recap,
|
|
3537
|
-
events: content,
|
|
3538
|
-
usage,
|
|
3539
|
-
}, {
|
|
3540
|
-
agentKind,
|
|
3541
|
-
headerText: `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`,
|
|
3542
|
-
title: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
|
|
3543
|
-
usageResult: 'success',
|
|
3544
|
-
emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
|
|
3545
|
-
actions,
|
|
3546
|
-
});
|
|
3547
|
-
return { card };
|
|
3548
|
-
}
|
|
3549
|
-
// `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
|
|
3550
|
-
// Use agentKind to select the appropriate sessionReader
|
|
3551
|
-
if (!cwd)
|
|
3552
|
-
return { text: '请先 /cd 设置工作目录' };
|
|
3553
|
-
const pageSize = clampInt(limit ?? RESUME_PAGE_SIZE, 1, RESUME_PAGE_SIZE);
|
|
3554
|
-
let reader;
|
|
3555
|
-
// Use this.sessionReader when agentKind matches default, otherwise get from registry
|
|
3556
|
-
if (agentKind === this.config.defaultAgent) {
|
|
3557
|
-
reader = this.sessionReader;
|
|
3558
|
-
}
|
|
3559
|
-
else {
|
|
3560
|
-
reader = this.sessionReaderRegistry.get(agentKind);
|
|
3561
|
-
}
|
|
3562
|
-
// Fetch the requested page. If the offset is stale (sessions added/removed
|
|
3563
|
-
// since the page was rendered), clamp it to the last valid page start and
|
|
3564
|
-
// re-fetch so the card never shows a misaligned/empty page.
|
|
3565
|
-
let pageOffset = offset;
|
|
3566
|
-
let pageResult;
|
|
3567
|
-
try {
|
|
3568
|
-
pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
|
|
3569
|
-
// 末页起点对齐页边界((总页数-1)*pageSize,如 25 条/页 20 → offset 20),
|
|
3570
|
-
// 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
|
|
3571
|
-
const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
|
|
3572
|
-
if (pageOffset < 0 || pageOffset > maxOffset) {
|
|
3573
|
-
pageOffset = clampInt(pageOffset, 0, maxOffset);
|
|
3574
|
-
pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
catch (err) {
|
|
3578
|
-
// 读取失败必须给用户可见反馈,而不是误导性的「没有 session 记录」
|
|
3579
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3580
|
-
return { text: `读取 ${agentDisplayName(agentKind)} session 列表失败: ${msg}` };
|
|
3581
|
-
}
|
|
3582
|
-
const { sessions: allSessions, total } = pageResult;
|
|
3583
|
-
if (allSessions.length === 0) {
|
|
3584
|
-
const agentName = agentDisplayName(agentKind);
|
|
3585
|
-
const hint = agentKind === this.config.defaultAgent
|
|
3586
|
-
? `\n提示:可使用 /resume <agent> 切换查看其他 Agent 的 session`
|
|
3587
|
-
: '';
|
|
3588
|
-
return { text: `当前目录没有 ${agentName} session 记录\n${cwd}${hint}` };
|
|
3589
|
-
}
|
|
3590
|
-
// Pre-fetch aiTitle/recap/displayTitle only for the first few rows —
|
|
3591
|
-
// full reads are expensive (whole-file JSONL scan). Remaining rows fall
|
|
3592
|
-
// back to the lightweight summary already returned by listSessions.
|
|
3593
|
-
const sessionMeta = new Map();
|
|
3594
|
-
const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
|
|
3595
|
-
for (const s of allSessions.slice(0, prefetchCount)) {
|
|
3596
|
-
// 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
|
|
3597
|
-
// DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
|
|
3598
|
-
// `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
|
|
3599
|
-
// 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
|
|
3600
|
-
// `session/list` 就已经给全了。
|
|
3601
|
-
if (s.title || s.lastUserMessage || s.lastAssistantMessage)
|
|
3602
|
-
continue;
|
|
3603
|
-
// 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
|
|
3604
|
-
// readSessionSummary,避免对前 N 条各做一次全量 JSONL 解析来构造
|
|
3605
|
-
// 立即被丢弃的 events 数组。其他 agent 无此方法时回退全量读取再取字段。
|
|
3606
|
-
const meta = reader.readSessionSummary
|
|
3607
|
-
? reader.readSessionSummary(s.sessionId, cwd)
|
|
3608
|
-
: (() => {
|
|
3609
|
-
const c = reader.readSessionContent(s.sessionId, cwd);
|
|
3610
|
-
return { aiTitle: c.aiTitle, recap: c.recap, displayTitle: c.displayTitle };
|
|
3611
|
-
})();
|
|
3612
|
-
sessionMeta.set(s.sessionId, {
|
|
3613
|
-
aiTitle: meta.aiTitle,
|
|
3614
|
-
recap: meta.recap,
|
|
3615
|
-
displayTitle: meta.displayTitle,
|
|
3616
|
-
});
|
|
3617
|
-
}
|
|
3618
|
-
const now = Date.now();
|
|
3619
|
-
function formatRelativeTime(mtimeMs) {
|
|
3620
|
-
const diffSec = Math.floor((now - mtimeMs) / 1000);
|
|
3621
|
-
if (diffSec < 60)
|
|
3622
|
-
return '刚刚';
|
|
3623
|
-
if (diffSec < 3600)
|
|
3624
|
-
return `${Math.floor(diffSec / 60)} 分钟前`;
|
|
3625
|
-
if (diffSec < 86400)
|
|
3626
|
-
return `${Math.floor(diffSec / 3600)} 小时前`;
|
|
3627
|
-
if (diffSec < 604800)
|
|
3628
|
-
return `${Math.floor(diffSec / 86400)} 天前`;
|
|
3629
|
-
return new Date(mtimeMs).toLocaleDateString('zh-CN');
|
|
3630
|
-
}
|
|
3631
|
-
const elements = [
|
|
3632
|
-
{
|
|
3633
|
-
tag: 'div',
|
|
3634
|
-
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${cwd}\`` },
|
|
3635
|
-
},
|
|
3636
|
-
];
|
|
3637
|
-
// Get current sessionId for the agent being listed
|
|
3638
|
-
const currentId = entry?.sessions?.get(agentKind) ?? '';
|
|
3639
|
-
for (const s of allSessions) {
|
|
3640
|
-
// Only mark as current if we're listing the defaultAgent's sessions
|
|
3641
|
-
const isCurrent = agentKind === this.config.defaultAgent && s.sessionId === currentId;
|
|
3642
|
-
const timeStr = formatRelativeTime(s.mtime);
|
|
3643
|
-
const buttonText = isCurrent ? '✓ 当前会话' : '恢复此会话';
|
|
3644
|
-
const meta = sessionMeta.get(s.sessionId);
|
|
3645
|
-
// Title priority:
|
|
3646
|
-
// 1. 行级 title(服务端投影,DSH)→ 「标题」。
|
|
3647
|
-
// 2. Prefetched displayTitle (aiTitle or last user message) keeps its
|
|
3648
|
-
// original label (AI 标题 / 最近输入).
|
|
3649
|
-
// 3. Otherwise the lightweight summary renders under the neutral
|
|
3650
|
-
// "会话摘要" label — summary is the first user message, not the
|
|
3651
|
-
// last input, so "最近输入" would be semantically wrong.
|
|
3652
|
-
// 4. Placeholder summaries (no user message) render no title section
|
|
3653
|
-
// at all — never leak '(无摘要)'/'(no user message)'/'New Session'.
|
|
3654
|
-
const summaryIsPlaceholder = RESUME_SUMMARY_PLACEHOLDERS.has(s.summary.trim());
|
|
3655
|
-
const prefetchedTitle = meta?.displayTitle;
|
|
3656
|
-
// 行级 title 也过占位符过滤:DSH 对空会话可能给 "New Session",旧路径
|
|
3657
|
-
// 的 summary 会被挡掉,新路径如果不过滤就会把它当真实标题渲染。
|
|
3658
|
-
const rowTitleRaw = s.title?.trim() ?? '';
|
|
3659
|
-
const rowTitle = rowTitleRaw && !RESUME_SUMMARY_PLACEHOLDERS.has(rowTitleRaw) ? rowTitleRaw : undefined;
|
|
3660
|
-
// aiTitle 不能单独作为"预取标题"键:若 displayTitle 缺失(理论上 opencode
|
|
3661
|
-
// info.title 非空但 export 无 user text),标题兜底是 summary,label 必须
|
|
3662
|
-
// 是中性"会话摘要"而不是"最近输入"(Review 边缘分支)。
|
|
3663
|
-
const titleText = prefetchedTitle ?? rowTitle ?? (summaryIsPlaceholder ? '' : s.summary);
|
|
3664
|
-
const titleLabel = prefetchedTitle
|
|
3665
|
-
? meta?.aiTitle
|
|
3666
|
-
? 'AI 标题'
|
|
3667
|
-
: '最近输入'
|
|
3668
|
-
: rowTitle
|
|
3669
|
-
? '标题'
|
|
3670
|
-
: summaryIsPlaceholder
|
|
3671
|
-
? ''
|
|
3672
|
-
: '会话摘要';
|
|
3673
|
-
const sections = [];
|
|
3674
|
-
if (titleText) {
|
|
3675
|
-
sections.push(`🏷️ **${titleLabel}**\n${titleText.slice(0, 80)}${titleText.length > 80 ? '...' : ''}`);
|
|
3676
|
-
}
|
|
3677
|
-
if (meta?.recap) {
|
|
3678
|
-
sections.push(`📝 **Recap**\n${meta.recap.slice(0, 60)}${meta.recap.length > 60 ? '...' : ''}`);
|
|
3679
|
-
}
|
|
3680
|
-
// 行级预览(DSH 服务端投影):最后一句我说了什么 / 最后一句它答了什么。
|
|
3681
|
-
// 只对提供了字段的 reader 渲染,其他 agent 的卡片一个字都不变。
|
|
3682
|
-
if (s.lastUserMessage) {
|
|
3683
|
-
sections.push(`✏️ **最近输入**\n${sessionPreviewLine(s.lastUserMessage, RESUME_PREVIEW_MAX)}`);
|
|
3684
|
-
}
|
|
3685
|
-
if (s.lastAssistantMessage) {
|
|
3686
|
-
sections.push(`📤 **最后输出**\n${sessionPreviewLine(s.lastAssistantMessage, RESUME_PREVIEW_MAX)}`);
|
|
3687
|
-
}
|
|
3688
|
-
const infoStr = sections.length > 0 ? '\n' + sections.join('\n──\n') : '';
|
|
3689
|
-
elements.push({ tag: 'hr' });
|
|
3690
|
-
// Both current and non-current sessions get a button for visual consistency
|
|
3691
|
-
elements.push({
|
|
3692
|
-
tag: 'div',
|
|
3693
|
-
text: {
|
|
3694
|
-
tag: 'lark_md',
|
|
3695
|
-
content: `**${s.sessionId}**${infoStr}\n⏱ ${timeStr}${isCurrent ? ' •' : ''}`,
|
|
3696
|
-
},
|
|
3697
|
-
});
|
|
3698
|
-
elements.push({
|
|
3699
|
-
tag: 'column_set',
|
|
3700
|
-
columns: [
|
|
3701
|
-
{
|
|
3702
|
-
tag: 'column',
|
|
3703
|
-
width: 'auto',
|
|
3704
|
-
elements: [
|
|
3705
|
-
cardButton(buttonText, { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind }, { type: isCurrent ? 'default' : 'primary', disabled: isCurrent }),
|
|
3706
|
-
],
|
|
3707
|
-
},
|
|
3708
|
-
],
|
|
3709
|
-
});
|
|
3710
|
-
}
|
|
3711
|
-
// Pagination bar (only shown when there are more sessions than pageSize)
|
|
3712
|
-
if (total > pageSize) {
|
|
3713
|
-
const { totalPages, currentPage } = pageInfo(total, pageOffset, pageSize);
|
|
3714
|
-
const bar = paginationBar({
|
|
3715
|
-
cmd: 'resume.page',
|
|
3716
|
-
offset: pageOffset,
|
|
3717
|
-
pageSize,
|
|
3718
|
-
total,
|
|
3719
|
-
extra: { agent: agentKind, pageSize },
|
|
3720
|
-
label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
|
|
3721
|
-
});
|
|
3722
|
-
elements.push({ tag: 'hr' });
|
|
3723
|
-
elements.push(...bar);
|
|
3724
|
-
}
|
|
3725
|
-
const card = {
|
|
3726
|
-
schema: '2.0',
|
|
3727
|
-
config: { wide_screen_mode: true },
|
|
3728
|
-
header: {
|
|
3729
|
-
title: {
|
|
3730
|
-
tag: 'plain_text',
|
|
3731
|
-
content: `🔁 恢复历史会话 · ${agentDisplayName(agentKind)}`,
|
|
3732
|
-
},
|
|
3733
|
-
},
|
|
3734
|
-
body: { elements },
|
|
3735
|
-
};
|
|
3736
|
-
return { card };
|
|
2782
|
+
return buildResumeCommand(args, ctx, offset, {
|
|
2783
|
+
defaultAgent: this.config.defaultAgent,
|
|
2784
|
+
getSessionEntry: (userId) => this.sessionStore.get(userId),
|
|
2785
|
+
setSessionId: (userId, agent, sessionId, cwd) => this.sessionStore.setSessionId(userId, agent, sessionId, cwd),
|
|
2786
|
+
defaultReader: () => this.sessionReader,
|
|
2787
|
+
readerFor: (agent) => this.sessionReaderRegistry.get(agent),
|
|
2788
|
+
dshPresetMismatch: (reader, sessionId, cwd) => this.dshPresetMismatch(reader, sessionId, cwd),
|
|
2789
|
+
readDisplay: (sessionId, cwd, opts) => this.readSessionDisplayState(sessionId, cwd, opts),
|
|
2790
|
+
buildActions: (opts) => this.buildResumeActionButtons(opts),
|
|
2791
|
+
canCompact: (agent, cwd) => this.canCompactSession(agent, cwd),
|
|
2792
|
+
});
|
|
3737
2793
|
}
|
|
3738
2794
|
cmdActive(_args, _ctx, offset = 0) {
|
|
3739
2795
|
// List all active runs from lark-remote memory (not from file system scan)
|
|
@@ -3957,216 +3013,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3957
3013
|
// 使用共享渲染模块构建卡片(Agent 选择器作为普通 select 字段参与渲染)
|
|
3958
3014
|
return { card: buildConfigCardFromTabs(tabs, displayConfig) };
|
|
3959
3015
|
}
|
|
3960
|
-
/** /order 实现(public
|
|
3961
|
-
cmdOrder(args,
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
this.orderStore.
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
}
|
|
3971
|
-
else {
|
|
3972
|
-
// Pagination calculations
|
|
3973
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageOrders, hasPagination, } = pageSlice(allOrders, offset, ORDER_PAGE_SIZE);
|
|
3974
|
-
for (let i = 0; i < pageOrders.length; i++) {
|
|
3975
|
-
const order = pageOrders[i];
|
|
3976
|
-
const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
|
|
3977
|
-
// 文字在上方(有别名时用 `$name` 前缀标识触发词)
|
|
3978
|
-
elements.push({
|
|
3979
|
-
tag: 'div',
|
|
3980
|
-
text: {
|
|
3981
|
-
tag: 'lark_md',
|
|
3982
|
-
content: order.alias ? `\`$${order.alias}\` ${displayText}` : displayText,
|
|
3983
|
-
},
|
|
3984
|
-
});
|
|
3985
|
-
// 操作行:左侧别名按钮(无别名显示 +别名,有别名点击可改),
|
|
3986
|
-
// 右侧「删除指令」按钮。两列均 weighted 1:1,跨行对齐。
|
|
3987
|
-
const leftColumn = order.alias
|
|
3988
|
-
? [
|
|
3989
|
-
cardButton(`$${order.alias}`, { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
|
|
3990
|
-
cardButton('✕', {
|
|
3991
|
-
cmd: 'order.aliasRemove',
|
|
3992
|
-
orderId: order.id,
|
|
3993
|
-
aliasName: order.alias,
|
|
3994
|
-
offset: safeOffset,
|
|
3995
|
-
}, { type: 'default', size: 'small' }),
|
|
3996
|
-
]
|
|
3997
|
-
: [
|
|
3998
|
-
cardButton('+别名', { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
|
|
3999
|
-
];
|
|
4000
|
-
elements.push({
|
|
4001
|
-
tag: 'column_set',
|
|
4002
|
-
columns: [
|
|
4003
|
-
{
|
|
4004
|
-
tag: 'column',
|
|
4005
|
-
width: 'weighted',
|
|
4006
|
-
weight: 1,
|
|
4007
|
-
elements: leftColumn,
|
|
4008
|
-
},
|
|
4009
|
-
{
|
|
4010
|
-
tag: 'column',
|
|
4011
|
-
width: 'weighted',
|
|
4012
|
-
weight: 1,
|
|
4013
|
-
vertical_align: 'center',
|
|
4014
|
-
elements: [
|
|
4015
|
-
{
|
|
4016
|
-
tag: 'column_set',
|
|
4017
|
-
columns: [
|
|
4018
|
-
{
|
|
4019
|
-
tag: 'column',
|
|
4020
|
-
width: 'auto',
|
|
4021
|
-
elements: [
|
|
4022
|
-
cardButton('▶ 执行', { cmd: 'order.exec', orderId: order.id }, { type: 'primary', size: 'small' }),
|
|
4023
|
-
],
|
|
4024
|
-
},
|
|
4025
|
-
{
|
|
4026
|
-
// 编辑:弹蓝色 header 输入卡(CardKit 2.0 input,回车提交),
|
|
4027
|
-
// 镜像 order.aliasEdit 流程。order.textEdit 是 control-only action
|
|
4028
|
-
// (isImmediateAction 注册),不 spawn agent。
|
|
4029
|
-
tag: 'column',
|
|
4030
|
-
width: 'auto',
|
|
4031
|
-
elements: [
|
|
4032
|
-
cardButton('编辑', { cmd: 'order.textEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
|
|
4033
|
-
],
|
|
4034
|
-
},
|
|
4035
|
-
{
|
|
4036
|
-
tag: 'column',
|
|
4037
|
-
width: 'auto',
|
|
4038
|
-
elements: [
|
|
4039
|
-
cardButton('删除', { cmd: 'order.delete', orderId: order.id, offset: safeOffset }, { type: 'danger', size: 'small' }),
|
|
4040
|
-
],
|
|
4041
|
-
},
|
|
4042
|
-
],
|
|
4043
|
-
},
|
|
4044
|
-
],
|
|
4045
|
-
},
|
|
4046
|
-
],
|
|
4047
|
-
});
|
|
4048
|
-
// 非最后一项,添加分隔线
|
|
4049
|
-
if (i < pageOrders.length - 1) {
|
|
4050
|
-
elements.push({ tag: 'hr' });
|
|
4051
|
-
}
|
|
4052
|
-
}
|
|
4053
|
-
// Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
|
|
4054
|
-
if (hasPagination) {
|
|
4055
|
-
const bar = paginationBar({
|
|
4056
|
-
cmd: 'order.page',
|
|
4057
|
-
offset: safeOffset,
|
|
4058
|
-
pageSize: ORDER_PAGE_SIZE,
|
|
4059
|
-
total: totalCount,
|
|
4060
|
-
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
|
|
4061
|
-
});
|
|
4062
|
-
elements.push({ tag: 'hr' });
|
|
4063
|
-
elements.push(...bar);
|
|
4064
|
-
}
|
|
4065
|
-
}
|
|
4066
|
-
return {
|
|
4067
|
-
card: {
|
|
4068
|
-
schema: '2.0',
|
|
4069
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
4070
|
-
header: { title: { tag: 'plain_text', content: '📋 指令' }, template: 'turquoise' },
|
|
4071
|
-
body: { elements },
|
|
4072
|
-
},
|
|
4073
|
-
};
|
|
4074
|
-
}
|
|
4075
|
-
// /order alias — CLI 别名管理(卡片是主入口,命令是备选):
|
|
4076
|
-
// /order alias add <orderId|N> <name> — 给某条指令绑定别名
|
|
4077
|
-
// /order alias rm <orderId|N> — 移除某条指令的别名
|
|
4078
|
-
// /order alias list — 列出已绑定的别名(并入列表卡片)
|
|
4079
|
-
if (sub === 'alias') {
|
|
4080
|
-
const op = args[1];
|
|
4081
|
-
if (op === 'add') {
|
|
4082
|
-
const target = args[2];
|
|
4083
|
-
const name = args[3];
|
|
4084
|
-
if (!target || !name) {
|
|
4085
|
-
return { text: '用法: /order alias add <orderId|序号> <别名名>' };
|
|
4086
|
-
}
|
|
4087
|
-
return this.cmdOrderAliasAdd(target, name);
|
|
4088
|
-
}
|
|
4089
|
-
if (op === 'rm') {
|
|
4090
|
-
const target = args[2];
|
|
4091
|
-
if (!target) {
|
|
4092
|
-
return { text: '用法: /order alias rm <orderId|序号>' };
|
|
4093
|
-
}
|
|
4094
|
-
return this.cmdOrderAliasRemove(target);
|
|
4095
|
-
}
|
|
4096
|
-
if (!op || op === 'list') {
|
|
4097
|
-
// 别名管理列表并入列表卡片
|
|
4098
|
-
return this.cmdOrder([], _ctx, offset);
|
|
4099
|
-
}
|
|
4100
|
-
return {
|
|
4101
|
-
text: '用法: /order alias [add <orderId|序号> <别名名> | rm <orderId|序号> | list]',
|
|
4102
|
-
};
|
|
4103
|
-
}
|
|
4104
|
-
// /order save <text>
|
|
4105
|
-
if (sub === 'save') {
|
|
4106
|
-
const text = args.slice(1).join(' ');
|
|
4107
|
-
if (!text) {
|
|
4108
|
-
return { text: '用法: /order save <指令文本>' };
|
|
4109
|
-
}
|
|
4110
|
-
try {
|
|
4111
|
-
this.orderStore.save(text);
|
|
4112
|
-
return { text: `✅ 已保存指令: ${text.slice(0, 50)}${text.length > 50 ? '...' : ''}` };
|
|
4113
|
-
}
|
|
4114
|
-
catch (err) {
|
|
4115
|
-
return { text: `保存失败: ${err.message}` };
|
|
4116
|
-
}
|
|
4117
|
-
}
|
|
4118
|
-
// /order edit <orderId|序号> <新文本> — CLI 备选路径(卡片是主入口)。
|
|
4119
|
-
// 与 /order save / alias add|rm 对称;复用 resolveOrderTarget。
|
|
4120
|
-
// 与卡片路径(handleOrderTextInput)对齐 trim 语义,避免「卡片 trim、CLI 不 trim」
|
|
4121
|
-
// 两条入口对同一文本存出不同结果。
|
|
4122
|
-
if (sub === 'edit') {
|
|
4123
|
-
const target = args[1];
|
|
4124
|
-
const newText = args.slice(2).join(' ').trim();
|
|
4125
|
-
if (!target || !newText) {
|
|
4126
|
-
return { text: '用法: /order edit <orderId|序号> <新文本>' };
|
|
4127
|
-
}
|
|
4128
|
-
const order = this.resolveOrderTarget(target);
|
|
4129
|
-
if (!order)
|
|
4130
|
-
return { text: `指令不存在: ${target}` };
|
|
4131
|
-
try {
|
|
4132
|
-
this.orderStore.updateText(order.id, newText);
|
|
4133
|
-
const preview = newText.length > 50 ? newText.slice(0, 50) + '...' : newText;
|
|
4134
|
-
return { text: `✅ 已更新指令: ${preview}` };
|
|
4135
|
-
}
|
|
4136
|
-
catch (err) {
|
|
4137
|
-
return { text: `保存失败: ${err.message}` };
|
|
4138
|
-
}
|
|
4139
|
-
}
|
|
4140
|
-
return {
|
|
4141
|
-
text: '用法: /order [list|save <text>|edit <orderId|序号> <新文本>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
|
|
4142
|
-
};
|
|
4143
|
-
}
|
|
4144
|
-
/** /order alias add:按 orderId 或列表序号(1 基)解析目标指令并绑定别名。 */
|
|
4145
|
-
cmdOrderAliasAdd(target, name) {
|
|
4146
|
-
const order = this.resolveOrderTarget(target);
|
|
4147
|
-
if (!order)
|
|
4148
|
-
return { text: `指令不存在: ${target}` };
|
|
4149
|
-
try {
|
|
4150
|
-
this.orderStore.setAlias(order.id, name);
|
|
4151
|
-
return { text: `✅ 已给指令绑定别名 $${name}` };
|
|
4152
|
-
}
|
|
4153
|
-
catch (err) {
|
|
4154
|
-
return { text: `保存失败: ${err.message}` };
|
|
4155
|
-
}
|
|
4156
|
-
}
|
|
4157
|
-
/** /order alias rm:按 orderId 或列表序号(1 基)移除指令的别名。 */
|
|
4158
|
-
cmdOrderAliasRemove(target) {
|
|
4159
|
-
const order = this.resolveOrderTarget(target);
|
|
4160
|
-
if (!order)
|
|
4161
|
-
return { text: `指令不存在: ${target}` };
|
|
4162
|
-
if (!order.alias)
|
|
4163
|
-
return { text: '该指令没有别名' };
|
|
4164
|
-
// 先读别名名再解绑:setAlias(undefined) 会 delete 同一对象引用上的 alias 字段。
|
|
4165
|
-
const aliasName = order.alias;
|
|
4166
|
-
this.orderStore.setAlias(order.id, undefined);
|
|
4167
|
-
return { text: `✅ 已移除别名 $${aliasName}` };
|
|
3016
|
+
/** /order 实现(public:测试直接调用)。 */
|
|
3017
|
+
cmdOrder(args, ctx, offset = 0) {
|
|
3018
|
+
return buildOrderCommand(args, ctx, offset, {
|
|
3019
|
+
reload: () => this.orderStore.reload(),
|
|
3020
|
+
list: () => this.orderStore.get(),
|
|
3021
|
+
save: (text) => this.orderStore.save(text),
|
|
3022
|
+
updateText: (id, text) => this.orderStore.updateText(id, text),
|
|
3023
|
+
setAlias: (id, alias) => this.orderStore.setAlias(id, alias),
|
|
3024
|
+
resolveTarget: (target) => this.resolveOrderTarget(target),
|
|
3025
|
+
});
|
|
4168
3026
|
}
|
|
4169
|
-
/** 将 `/order alias add|rm` 的目标解析为 order:优先 orderId 精确匹配,回退列表序号(1 基)。 */
|
|
4170
3027
|
resolveOrderTarget(target) {
|
|
4171
3028
|
const all = this.orderStore.get();
|
|
4172
3029
|
const byId = all.find((o) => o.id === target);
|