lark-remote 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.en.md +2 -6
  3. package/README.md +3 -7
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/inbound-media.d.ts +0 -3
  7. package/dist/bridge/inbound-media.js +0 -1
  8. package/dist/bridge/index.d.ts +7 -7
  9. package/dist/bridge/index.js +100 -133
  10. package/dist/bridge/queue-manager.js +4 -3
  11. package/dist/card/approval-render.js +39 -94
  12. package/dist/card/bash-renderer.js +12 -21
  13. package/dist/card/card-shared.d.ts +14 -5
  14. package/dist/card/card-shared.js +23 -34
  15. package/dist/card/run-renderer.d.ts +0 -22
  16. package/dist/card/run-renderer.js +206 -153
  17. package/dist/card/run-state.d.ts +29 -1
  18. package/dist/card/run-state.js +172 -84
  19. package/dist/card/text-truncate.d.ts +0 -9
  20. package/dist/card/text-truncate.js +0 -9
  21. package/dist/card/tool-render.d.ts +4 -1
  22. package/dist/card/tool-render.js +76 -32
  23. package/dist/cli.js +2 -2
  24. package/dist/common/fs.d.ts +14 -0
  25. package/dist/common/fs.js +57 -3
  26. package/dist/common/truncate.d.ts +0 -6
  27. package/dist/common/truncate.js +1 -1
  28. package/dist/common/ttl-cache.d.ts +24 -0
  29. package/dist/common/ttl-cache.js +53 -0
  30. package/dist/config/codex-config.js +14 -11
  31. package/dist/config/index.d.ts +0 -8
  32. package/dist/config/index.js +0 -16
  33. package/dist/config/kimi-config.d.ts +1 -1
  34. package/dist/config/kimi-config.js +2 -1
  35. package/dist/config/opencode-config.d.ts +1 -1
  36. package/dist/config/opencode-config.js +16 -10
  37. package/dist/config/qr.d.ts +36 -0
  38. package/dist/config/qr.js +124 -0
  39. package/dist/config/wizard.js +69 -27
  40. package/dist/connector/index.d.ts +3 -2
  41. package/dist/connector/index.js +5 -3
  42. package/dist/index.js +14 -41
  43. package/dist/instance-lock.js +7 -0
  44. package/dist/persistence/atomic-write.d.ts +2 -0
  45. package/dist/persistence/atomic-write.js +20 -5
  46. package/dist/persistence/load-json-file.d.ts +1 -2
  47. package/dist/persistence/load-json-file.js +2 -3
  48. package/dist/platform/agent-stopper.d.ts +22 -0
  49. package/dist/platform/agent-stopper.js +19 -0
  50. package/dist/platform/command.d.ts +11 -0
  51. package/dist/platform/command.js +107 -0
  52. package/dist/platform/identity.d.ts +33 -0
  53. package/dist/platform/identity.js +187 -0
  54. package/dist/platform/path.d.ts +24 -0
  55. package/dist/platform/path.js +110 -0
  56. package/dist/platform/select.d.ts +9 -0
  57. package/dist/platform/select.js +12 -0
  58. package/dist/platform/shell.d.ts +51 -0
  59. package/dist/platform/shell.js +75 -0
  60. package/dist/platform/spawn.d.ts +41 -0
  61. package/dist/platform/spawn.js +77 -0
  62. package/dist/platform/terminator-posix.d.ts +38 -0
  63. package/dist/platform/terminator-posix.js +103 -0
  64. package/dist/platform/terminator-win32.d.ts +21 -0
  65. package/dist/platform/terminator-win32.js +110 -0
  66. package/dist/platform/terminator.d.ts +44 -0
  67. package/dist/platform/terminator.js +24 -0
  68. package/dist/platform/types.d.ts +23 -0
  69. package/dist/platform/types.js +5 -0
  70. package/dist/restart.js +4 -2
  71. package/dist/router/card-helpers.d.ts +33 -6
  72. package/dist/router/card-helpers.js +67 -11
  73. package/dist/router/config/common/render.d.ts +1 -1
  74. package/dist/router/config/common/render.js +1 -1
  75. package/dist/router/config-diff.d.ts +4 -0
  76. package/dist/router/config-diff.js +70 -0
  77. package/dist/router/index.d.ts +73 -6
  78. package/dist/router/index.js +663 -590
  79. package/dist/router/utils.d.ts +6 -0
  80. package/dist/router/utils.js +12 -0
  81. package/dist/runner/bash/runner.d.ts +9 -2
  82. package/dist/runner/bash/runner.js +45 -20
  83. package/dist/runner/claude/session.d.ts +4 -4
  84. package/dist/runner/claude/session.js +18 -9
  85. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  86. package/dist/runner/codex/app-server/runner.d.ts +1 -16
  87. package/dist/runner/codex/app-server/runner.js +2 -2
  88. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  89. package/dist/runner/codex/app-server/translator.js +146 -0
  90. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  91. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  92. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  93. package/dist/runner/common/acp/base-translator.js +271 -0
  94. package/dist/runner/common/acp/protocol-helpers.d.ts +70 -0
  95. package/dist/runner/common/acp/protocol-helpers.js +82 -0
  96. package/dist/runner/common/connection-based-runner.d.ts +0 -2
  97. package/dist/runner/common/connection-based-runner.js +1 -1
  98. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  99. package/dist/runner/common/jsonrpc/transport.js +25 -5
  100. package/dist/runner/common/process-stopper.d.ts +4 -26
  101. package/dist/runner/common/process-stopper.js +4 -76
  102. package/dist/runner/common/runner-utils.d.ts +0 -5
  103. package/dist/runner/common/runner-utils.js +0 -7
  104. package/dist/runner/common/spawning-runner.d.ts +42 -132
  105. package/dist/runner/common/spawning-runner.js +52 -271
  106. package/dist/runner/common/usage.d.ts +2 -0
  107. package/dist/runner/common/usage.js +2 -0
  108. package/dist/runner/dsh/index.d.ts +0 -2
  109. package/dist/runner/dsh/index.js +0 -2
  110. package/dist/runner/dsh/runner.d.ts +0 -2
  111. package/dist/runner/dsh/runner.js +1 -1
  112. package/dist/runner/index.d.ts +1 -1
  113. package/dist/runner/kimi/acp/runner.d.ts +63 -53
  114. package/dist/runner/kimi/acp/runner.js +189 -334
  115. package/dist/runner/kimi/acp/translator.d.ts +18 -101
  116. package/dist/runner/kimi/acp/translator.js +61 -255
  117. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  118. package/dist/runner/opencode/acp/runner.js +74 -297
  119. package/dist/runner/opencode/acp/translator.d.ts +28 -104
  120. package/dist/runner/opencode/acp/translator.js +54 -249
  121. package/dist/runner/pi/rpc/index.d.ts +0 -2
  122. package/dist/runner/pi/rpc/index.js +0 -2
  123. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  124. package/dist/runner/pi/rpc/translator.js +47 -9
  125. package/dist/runner/probe.d.ts +8 -11
  126. package/dist/runner/probe.js +17 -21
  127. package/dist/runner/registry.d.ts +4 -4
  128. package/dist/runner/registry.js +4 -4
  129. package/dist/runner/resolve-agent-choices.js +6 -2
  130. package/dist/runner/sync-agent-choices.js +1 -1
  131. package/dist/runner/types.d.ts +38 -1
  132. package/dist/session/claude/session-index.js +3 -1
  133. package/dist/session/claude/sessions.js +37 -64
  134. package/dist/session/codex/rollout-reader.d.ts +10 -14
  135. package/dist/session/codex/rollout-reader.js +126 -48
  136. package/dist/session/common/constants.d.ts +7 -0
  137. package/dist/session/common/constants.js +9 -0
  138. package/dist/session/common/pagination.d.ts +0 -2
  139. package/dist/session/common/pagination.js +1 -1
  140. package/dist/session/common/recency.d.ts +7 -0
  141. package/dist/session/common/recency.js +10 -0
  142. package/dist/session/common/two-pass.d.ts +22 -0
  143. package/dist/session/common/two-pass.js +70 -0
  144. package/dist/session/common/usage-accumulator.d.ts +14 -0
  145. package/dist/session/common/usage-accumulator.js +15 -0
  146. package/dist/session/dsh/sessions.js +15 -11
  147. package/dist/session/kimi/sessions.d.ts +24 -4
  148. package/dist/session/kimi/sessions.js +99 -103
  149. package/dist/session/opencode/sessions.d.ts +2 -14
  150. package/dist/session/opencode/sessions.js +35 -15
  151. package/dist/session/pi/sessions.js +64 -95
  152. package/dist/update/index.d.ts +2 -3
  153. package/dist/update/index.js +1 -2
  154. package/dist/update/install.d.ts +2 -1
  155. package/dist/update/install.js +58 -9
  156. package/dist/update/version-check.d.ts +5 -0
  157. package/dist/update/version-check.js +9 -0
  158. package/package.json +9 -7
  159. package/dist/update/startup-hint.d.ts +0 -5
  160. package/dist/update/startup-hint.js +0 -11
@@ -2,14 +2,15 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, walkNestedContainer, getAgentConfig, } from '../config/index.js';
5
+ import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, getAgentConfig, } from '../config/index.js';
6
6
  import { syncAgentChoices } from '../runner/index.js';
7
7
  import { WorkspaceStore } from '../workspace/index.js';
8
8
  import { OrderStore } from '../order/index.js';
9
9
  import { resolveAlias } from '../order/alias-resolve.js';
10
+ import { diffConfig, setNestedValue } from './config-diff.js';
10
11
  import { getLogger } from '../logger/index.js';
11
12
  import { activeRunUsage, clampInt } from './utils.js';
12
- import { markdownDiv, buildSessionHistoryCard, paginationBar } from './card-helpers.js';
13
+ import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
13
14
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
14
15
  import { atomicWrite } from '../persistence/atomic-write.js';
15
16
  import { checkLatestVersion as defaultCheckLatestVersion, isNewer as defaultIsNewer, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
@@ -24,7 +25,40 @@ const AUTO_RESUME_MAX_EVENTS = 5;
24
25
  /** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
25
26
  const RESUME_PAGE_SIZE = 5;
26
27
  /** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
28
+ /**
29
+ * 分页算术(原先 5 处内联且 clamp 语义漂移:/ls 不 clamp、/active clamp 到
30
+ * totalCount-1 会把过期 offset 泄漏成残页)。统一 clamp 到最后一页边界。
31
+ */
32
+ function pageInfo(totalCount, offset, pageSize) {
33
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
34
+ const maxOffset = Math.max(0, (totalPages - 1) * pageSize);
35
+ const safeOffset = clampInt(offset, 0, maxOffset);
36
+ const currentPage = Math.floor(safeOffset / pageSize) + 1;
37
+ return { totalPages, safeOffset, currentPage };
38
+ }
39
+ /** 在 pageInfo 之上切出当前页条目。 */
40
+ function pageSlice(items, offset, pageSize) {
41
+ const { totalPages, safeOffset, currentPage } = pageInfo(items.length, offset, pageSize);
42
+ return {
43
+ totalCount: items.length,
44
+ totalPages,
45
+ safeOffset,
46
+ currentPage,
47
+ pageItems: items.slice(safeOffset, safeOffset + pageSize),
48
+ hasPagination: items.length > pageSize,
49
+ };
50
+ }
27
51
  const ACTIVE_PAGE_SIZE = 20;
52
+ /** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
53
+ function formatBytes(bytes) {
54
+ if (bytes < 1024)
55
+ return `${bytes}B`;
56
+ if (bytes < 1024 * 1024)
57
+ return `${(bytes / 1024).toFixed(1)}KB`;
58
+ if (bytes < 1024 * 1024 * 1024)
59
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
60
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
61
+ }
28
62
  /**
29
63
  * /order 列表页大小;指令超过此数量时显示分页导航栏。
30
64
  * 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
@@ -45,6 +79,9 @@ const WS_PAGE_SIZE = 5;
45
79
  * 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
46
80
  * 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
47
81
  *
82
+ * 「跳转页码」输入框(paginationBar 内置)的提交值优先于 offset;非法输入
83
+ * 返回 `error` 文案,调用方应回错误 toast 且不刷新卡片。
84
+ *
48
85
  * resume.page / active.page 共用,避免两处重复实现分页归一化。
49
86
  */
50
87
  function normalizePageArgs(value, maxPageSize) {
@@ -52,6 +89,13 @@ function normalizePageArgs(value, maxPageSize) {
52
89
  const pageSize = Number.isFinite(rawPageSize)
53
90
  ? clampInt(Math.trunc(rawPageSize), 1, maxPageSize)
54
91
  : maxPageSize;
92
+ const jump = parseJumpOffset(value.inputValue, pageSize);
93
+ if (jump === null) {
94
+ return { pageSize, alignedOffset: 0, error: PAGE_JUMP_INVALID_HINT };
95
+ }
96
+ if (jump !== undefined) {
97
+ return { pageSize, alignedOffset: Math.floor(jump / pageSize) * pageSize };
98
+ }
55
99
  const rawOffset = Number(value.offset);
56
100
  const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
57
101
  return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
@@ -65,6 +109,7 @@ const RESUME_CONTENT_PREFETCH = 5;
65
109
  */
66
110
  const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
67
111
  import { newSessionButton, resumeCompactButton, agentDisplayName } from '../card/card-shared.js';
112
+ import { displayName } from '../platform/path.js';
68
113
  /**
69
114
  * Card-action commands that must run with enqueue({ immediate: true }).
70
115
  * These bypass the serial queue and execute immediately.
@@ -91,47 +136,174 @@ export function isImmediateAction(cmd) {
91
136
  // help.* wildcard: any command starting with "help."
92
137
  if (cmd.startsWith('help.'))
93
138
  return true;
94
- return (cmd === 'new-session' ||
95
- cmd === 'stop' ||
96
- cmd === 'ls.file' ||
97
- cmd === 'ls.refresh' ||
98
- cmd === 'ls.browse' ||
99
- cmd === 'ls.switch' ||
100
- cmd === 'ls.page' ||
101
- cmd === 'resume.page' || // control operation: paginate only, never spawns claude
102
- cmd === 'active.page' || // control operation: paginate active card
103
- cmd === 'ws.page' || // control operation: paginate /ws list only
104
- cmd === 'ws.remove' ||
105
- cmd === 'ws.sort' || // control operation: toggle sort mode only
106
- cmd === 'resume.use' ||
107
- cmd === 'ws.use' ||
108
- cmd === 'queue.immediate' ||
109
- cmd === 'queue.cancel' ||
110
- cmd === 'queue.diagnose' ||
111
- cmd === 'queue.edit' ||
112
- cmd === 'queue.input' ||
113
- cmd === 'order.delete' ||
114
- cmd === 'order.page' ||
115
- cmd === 'order.aliasEdit' ||
116
- cmd === 'order.aliasInput' ||
117
- cmd === 'order.aliasRemove' ||
118
- cmd === 'order.textEdit' ||
119
- cmd === 'order.textInput' ||
120
- cmd === 'config.toggle' ||
121
- cmd === 'config.set' ||
122
- cmd === 'config.input' ||
123
- cmd === 'config.save' ||
124
- // 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
125
- // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
126
- // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
127
- cmd === 'approval.respond' ||
128
- cmd === 'approval.toggle' ||
129
- cmd === 'approval.answer' ||
130
- cmd === 'approval.answerSubmit' ||
131
- cmd === 'approval.answerCustom' ||
132
- cmd === 'approval.answerNote' ||
133
- cmd === 'approval.planFeedback');
139
+ // 名单在 APPROVAL_ACTION_SPECS 之后定义(模块初始化完成后才可能被调用)。
140
+ return IMMEDIATE_ACTION_CMDS.has(cmd);
141
+ }
142
+ const QUESTION_INDEX_PRESENT = (v) => v.questionIndex !== undefined;
143
+ const APPROVAL_ACTION_SPECS = {
144
+ 'approval.respond': {
145
+ missing: (v) => !v.runId || v.requestId === undefined || !v.decision || !v.nonce,
146
+ missingToast: '缺少审批响应参数',
147
+ run: (bridge, v) => bridge.handleApprovalRespond({
148
+ runId: v.runId,
149
+ requestId: v.requestId,
150
+ decision: v.decision,
151
+ nonce: v.nonce,
152
+ }),
153
+ successToast: '审批已提交',
154
+ failureToast: (msg) => {
155
+ // 审批已过期:给用户明确反馈,不静默不误导(2026-08-12 事故:点了允许无任何反馈)。
156
+ const content = /state=expired/.test(msg)
157
+ ? '⏰ 审批已过期,无法响应'
158
+ : `审批响应失败:${msg}`;
159
+ return { toast: { type: 'error', content } };
160
+ },
161
+ },
162
+ 'approval.toggle': {
163
+ missing: (v) => !v.runId || v.requestId === undefined || !v.permId,
164
+ missingToast: '缺少权限切换参数',
165
+ run: (bridge, v) => bridge.handleApprovalToggle({
166
+ runId: v.runId,
167
+ requestId: v.requestId,
168
+ permId: v.permId,
169
+ selected: v.selected ?? true,
170
+ }),
171
+ successToast: '已切换',
172
+ toastType: 'info',
173
+ failureToast: (msg) => ({ toast: { type: 'error', content: `权限切换失败:${msg}` } }),
174
+ },
175
+ 'approval.answer': {
176
+ missing: (v) => !v.runId || v.requestId === undefined || !QUESTION_INDEX_PRESENT(v) || !v.option || !v.nonce,
177
+ missingToast: '缺少问题答案参数',
178
+ run: (bridge, v) => bridge.handleApprovalAnswer({
179
+ runId: v.runId,
180
+ requestId: v.requestId,
181
+ questionIndex: v.questionIndex,
182
+ option: v.option,
183
+ nonce: v.nonce,
184
+ }),
185
+ successToast: '已选择',
186
+ },
187
+ 'approval.answerSubmit': {
188
+ missing: (v) => !v.runId || v.requestId === undefined || !QUESTION_INDEX_PRESENT(v) || !v.nonce,
189
+ missingToast: '缺少提交参数',
190
+ run: (bridge, v) => bridge.handleApprovalAnswerSubmit({
191
+ runId: v.runId,
192
+ requestId: v.requestId,
193
+ questionIndex: v.questionIndex,
194
+ nonce: v.nonce,
195
+ }),
196
+ successToast: '答案已提交',
197
+ },
198
+ 'approval.answerCustom': {
199
+ missing: (v) => !v.runId ||
200
+ v.requestId === undefined ||
201
+ !QUESTION_INDEX_PRESENT(v) ||
202
+ !v.nonce ||
203
+ !v.inputValue,
204
+ missingToast: '缺少答案文本参数',
205
+ run: (bridge, v) => bridge.handleApprovalAnswerCustom({
206
+ runId: v.runId,
207
+ requestId: v.requestId,
208
+ questionIndex: v.questionIndex,
209
+ text: v.inputValue,
210
+ nonce: v.nonce,
211
+ }),
212
+ successToast: '答案已提交',
213
+ },
214
+ 'approval.answerNote': {
215
+ missing: (v) => !v.runId ||
216
+ v.requestId === undefined ||
217
+ !QUESTION_INDEX_PRESENT(v) ||
218
+ !v.nonce ||
219
+ !v.inputValue,
220
+ missingToast: '缺少补充说明参数',
221
+ run: (bridge, v) => bridge.handleApprovalAnswerNote({
222
+ runId: v.runId,
223
+ requestId: v.requestId,
224
+ questionIndex: v.questionIndex,
225
+ text: v.inputValue,
226
+ nonce: v.nonce,
227
+ }),
228
+ successToast: '补充说明已保存',
229
+ },
230
+ 'approval.planFeedback': {
231
+ missing: (v) => !v.runId || v.requestId === undefined || !v.nonce || !v.inputValue,
232
+ missingToast: '缺少修改意见参数',
233
+ run: (bridge, v) => bridge.handleApprovalPlanFeedback({
234
+ runId: v.runId,
235
+ requestId: v.requestId,
236
+ text: v.inputValue,
237
+ nonce: v.nonce,
238
+ }),
239
+ successToast: '修改意见已保存',
240
+ failureToast: (msg) => ({ toast: { type: 'error', content: `修改意见保存失败:${msg}` } }),
241
+ },
242
+ };
243
+ // ===========================================================================
244
+ // W2.1 命令名单单一事实源
245
+ // 直返语义(cardAction 同步返回 toast 给飞书回调,index.ts 控制层据此不经
246
+ // 队列直接处理)与即时语义(绕串行队列 enqueueImmediate,§9.6)正交,两个
247
+ // 独立清单只共享审批命令名来源(APPROVAL_ACTION_SPECS keys)——历史上同一
248
+ // 份知识四份拷贝曾两次漂移(order.textInput 漏 immediate、answer 家族漏直返)。
249
+ // ===========================================================================
250
+ /** 审批命令名清单(APPROVAL_ACTION_SPECS keys,handleCardAction 分发共用)。 */
251
+ export const APPROVAL_ACTION_CMDS = Object.keys(APPROVAL_ACTION_SPECS);
252
+ /**
253
+ * 直返命令:点击后同步返回 toast 给飞书回调,不经队列。
254
+ * index.ts 控制层直返清单的唯一来源(勿在别处复制名单)。
255
+ */
256
+ export const DIRECT_RETURN_CMDS = new Set([
257
+ 'queue.input',
258
+ 'order.aliasInput',
259
+ 'order.aliasRemove',
260
+ 'order.textInput',
261
+ 'config.save',
262
+ ...APPROVAL_ACTION_CMDS,
263
+ ]);
264
+ /** 即时命令清单(绕串行队列 enqueueImmediate)。 */
265
+ const IMMEDIATE_ACTION_CMDS = new Set([
266
+ 'new-session',
267
+ 'stop',
268
+ 'ls.file',
269
+ 'ls.refresh',
270
+ 'ls.browse',
271
+ 'ls.switch',
272
+ 'ls.page', // control operation: paginate only, never spawns claude
273
+ 'resume.page', // control operation: paginate only, never spawns claude
274
+ 'active.page', // control operation: paginate active card
275
+ 'ws.page', // control operation: paginate /ws list only
276
+ 'ws.remove',
277
+ 'ws.sort', // control operation: toggle sort mode only
278
+ 'resume.use',
279
+ 'ws.use',
280
+ 'queue.immediate',
281
+ 'queue.cancel',
282
+ 'queue.diagnose',
283
+ 'queue.edit',
284
+ 'queue.input',
285
+ 'order.delete',
286
+ 'order.page',
287
+ 'order.aliasEdit',
288
+ 'order.aliasInput',
289
+ 'order.aliasRemove',
290
+ 'order.textEdit',
291
+ 'order.textInput',
292
+ 'config.toggle',
293
+ 'config.set',
294
+ 'config.input',
295
+ 'config.save',
296
+ // 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
297
+ // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
298
+ // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
299
+ ...APPROVAL_ACTION_CMDS,
300
+ ]);
301
+ /** W2.8 单源:payload.offset → 钳位 offset(原先 9 处逐字副本)。 */
302
+ function payloadOffset(value) {
303
+ return Math.max(0, Math.trunc(Number(value.offset) || 0));
134
304
  }
305
+ /** 卡片 cardAction payload 缺字段的统一报错文案(原先 12 处手写且已漂移出两种前缀)。 */
306
+ const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
135
307
  export class CommandRouter {
136
308
  /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
137
309
  static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
@@ -275,6 +447,11 @@ export class CommandRouter {
275
447
  * Validates payload and dispatches to the corresponding command.
276
448
  */
277
449
  async handleCardAction(value, ctx) {
450
+ // W2.1:审批家族以 APPROVAL_ACTION_SPECS keys 为单一来源分发
451
+ // (原 7 个 case 与 spec keys 是同一份知识的两份拷贝,已两次漂移)。
452
+ if (APPROVAL_ACTION_CMDS.includes(value.cmd)) {
453
+ return this.handleApprovalAction(value, ctx);
454
+ }
278
455
  switch (value.cmd) {
279
456
  case 'ls.file':
280
457
  await this.cardLsFile(value.path, ctx);
@@ -382,14 +559,6 @@ export class CommandRouter {
382
559
  // 调 updateCardInPlace → Feishu API 乱序到达导致 toggle 卡死。
383
560
  // 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
384
561
  return this.enqueueConfigAction(value, ctx);
385
- case 'approval.respond':
386
- case 'approval.toggle':
387
- case 'approval.answer':
388
- case 'approval.answerSubmit':
389
- case 'approval.answerCustom':
390
- case 'approval.answerNote':
391
- case 'approval.planFeedback':
392
- return this.handleApprovalAction(value, ctx);
393
562
  case 'codex.compact':
394
563
  await this.bridge.handleCodexCompact(value, ctx);
395
564
  return;
@@ -427,6 +596,14 @@ export class CommandRouter {
427
596
  * Routes to the appropriate bridge method based on value.cmd.
428
597
  * Returns a toast response for immediate user feedback.
429
598
  */
599
+ /** queue.* 卡片动作通用守卫:workspace/messageId 缺失时回复错误并返回 null。 */
600
+ async queuePayloadOrReply(value, ctx) {
601
+ if (!value.workspace || !value.messageId) {
602
+ await this.bridge.sendResult({ text: CARD_PAYLOAD_MISSING }, ctx);
603
+ return null;
604
+ }
605
+ return { workspace: value.workspace, messageId: value.messageId };
606
+ }
430
607
  /** 答案类操作的统一失败反馈:重复投递(同一 nonce 第二次点击)是已生效的
431
608
  * 中性事件,报 error 会误导(首次点击实际已成功)。 */
432
609
  answerFailureToast(msg) {
@@ -436,164 +613,30 @@ export class CommandRouter {
436
613
  return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
437
614
  }
438
615
  async handleApprovalAction(value, _ctx) {
439
- const { runId, requestId, decision, nonce, permId } = value;
440
- if (value.cmd === 'approval.respond') {
441
- if (!runId || requestId === undefined || !decision || !nonce) {
442
- return { toast: { type: 'error', content: '缺少审批响应参数' } };
443
- }
444
- try {
445
- await this.bridge.handleApprovalRespond({
446
- runId,
447
- requestId,
448
- decision,
449
- nonce,
450
- });
451
- return { toast: { type: 'success', content: '审批已提交' } };
452
- }
453
- catch (err) {
454
- const msg = err instanceof Error ? err.message : String(err);
455
- // 审批已过期:给用户明确反馈,不静默不误导(2026-08-12 事故:点了允许无任何反馈)。
456
- const content = /state=expired/.test(msg)
457
- ? '⏰ 审批已过期,无法响应'
458
- : `审批响应失败:${msg}`;
459
- return { toast: { type: 'error', content } };
460
- }
616
+ const spec = APPROVAL_ACTION_SPECS[value.cmd];
617
+ if (!spec) {
618
+ return { toast: { type: 'error', content: '未知的审批操作' } };
461
619
  }
462
- if (value.cmd === 'approval.toggle') {
463
- if (!runId || requestId === undefined || !permId) {
464
- return { toast: { type: 'error', content: '缺少权限切换参数' } };
465
- }
466
- try {
467
- await this.bridge.handleApprovalToggle({
468
- runId,
469
- requestId,
470
- permId,
471
- selected: value.selected ?? true,
472
- });
473
- return { toast: { type: 'info', content: '已切换' } };
474
- }
475
- catch (err) {
476
- const msg = err instanceof Error ? err.message : String(err);
477
- return { toast: { type: 'error', content: `权限切换失败:${msg}` } };
478
- }
479
- }
480
- if (value.cmd === 'approval.answer') {
481
- if (!runId ||
482
- requestId === undefined ||
483
- value.questionIndex === undefined ||
484
- !value.option ||
485
- !nonce) {
486
- return { toast: { type: 'error', content: '缺少问题答案参数' } };
487
- }
488
- try {
489
- await this.bridge.handleApprovalAnswer({
490
- runId,
491
- requestId,
492
- questionIndex: value.questionIndex,
493
- option: value.option,
494
- nonce,
495
- });
496
- return { toast: { type: 'success', content: '已选择' } };
497
- }
498
- catch (err) {
499
- const msg = err instanceof Error ? err.message : String(err);
500
- return this.answerFailureToast(msg);
501
- }
502
- }
503
- if (value.cmd === 'approval.answerSubmit') {
504
- if (!runId || requestId === undefined || value.questionIndex === undefined || !nonce) {
505
- return { toast: { type: 'error', content: '缺少提交参数' } };
506
- }
507
- try {
508
- await this.bridge.handleApprovalAnswerSubmit({
509
- runId,
510
- requestId,
511
- questionIndex: value.questionIndex,
512
- nonce,
513
- });
514
- return { toast: { type: 'success', content: '答案已提交' } };
515
- }
516
- catch (err) {
517
- const msg = err instanceof Error ? err.message : String(err);
518
- return this.answerFailureToast(msg);
519
- }
520
- }
521
- if (value.cmd === 'approval.answerCustom') {
522
- if (!runId ||
523
- requestId === undefined ||
524
- value.questionIndex === undefined ||
525
- !nonce ||
526
- !value.inputValue) {
527
- return { toast: { type: 'error', content: '缺少答案文本参数' } };
528
- }
529
- try {
530
- await this.bridge.handleApprovalAnswerCustom({
531
- runId,
532
- requestId,
533
- questionIndex: value.questionIndex,
534
- text: value.inputValue,
535
- nonce,
536
- });
537
- return { toast: { type: 'success', content: '答案已提交' } };
538
- }
539
- catch (err) {
540
- const msg = err instanceof Error ? err.message : String(err);
541
- return this.answerFailureToast(msg);
542
- }
620
+ if (spec.missing(value)) {
621
+ return { toast: { type: 'error', content: spec.missingToast } };
543
622
  }
544
- if (value.cmd === 'approval.answerNote') {
545
- if (!runId ||
546
- requestId === undefined ||
547
- value.questionIndex === undefined ||
548
- !nonce ||
549
- !value.inputValue) {
550
- return { toast: { type: 'error', content: '缺少补充说明参数' } };
551
- }
552
- try {
553
- await this.bridge.handleApprovalAnswerNote({
554
- runId,
555
- requestId,
556
- questionIndex: value.questionIndex,
557
- text: value.inputValue,
558
- nonce,
559
- });
560
- return { toast: { type: 'success', content: '补充说明已保存' } };
561
- }
562
- catch (err) {
563
- const msg = err instanceof Error ? err.message : String(err);
564
- return this.answerFailureToast(msg);
565
- }
623
+ try {
624
+ await spec.run(this.bridge, value);
625
+ return { toast: { type: spec.toastType ?? 'success', content: spec.successToast } };
566
626
  }
567
- if (value.cmd === 'approval.planFeedback') {
568
- if (!runId || requestId === undefined || !nonce || !value.inputValue) {
569
- return { toast: { type: 'error', content: '缺少修改意见参数' } };
570
- }
571
- try {
572
- await this.bridge.handleApprovalPlanFeedback({
573
- runId,
574
- requestId,
575
- text: value.inputValue,
576
- nonce,
577
- });
578
- return { toast: { type: 'success', content: '修改意见已保存' } };
579
- }
580
- catch (err) {
581
- const msg = err instanceof Error ? err.message : String(err);
582
- return { toast: { type: 'error', content: `修改意见保存失败:${msg}` } };
583
- }
627
+ catch (err) {
628
+ const msg = err instanceof Error ? err.message : String(err);
629
+ return spec.failureToast ? spec.failureToast(msg) : this.answerFailureToast(msg);
584
630
  }
585
- return { toast: { type: 'error', content: '未知的审批操作' } };
586
631
  }
587
632
  /**
588
633
  * Handle queue.cancel: remove this message from the queue.
589
634
  */
590
635
  async handleQueueCancel(value, ctx) {
591
- const workspace = value.workspace;
592
- const messageId = value.messageId;
593
- if (!workspace || !messageId) {
594
- await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
636
+ const payload = await this.queuePayloadOrReply(value, ctx);
637
+ if (!payload)
595
638
  return;
596
- }
639
+ const { workspace, messageId } = payload;
597
640
  const removed = this.bridge.removeFromQueue(workspace, messageId);
598
641
  if (removed) {
599
642
  // Update the queue card in-place to "cancelled" state
@@ -610,12 +653,10 @@ export class CommandRouter {
610
653
  * Instead of asking user to resend, we keep the target task in queue and let it execute.
611
654
  */
612
655
  async handleQueueImmediate(value, ctx) {
613
- const workspace = value.workspace;
614
- const messageId = value.messageId;
615
- if (!workspace || !messageId) {
616
- await this.bridge.sendResult({ text: '卡片 payload 缺少必要信息' }, ctx);
656
+ const payload = await this.queuePayloadOrReply(value, ctx);
657
+ if (!payload)
617
658
  return;
618
- }
659
+ const { workspace, messageId } = payload;
619
660
  // 1. Check target exists BEFORE any await. The queue chain can advance
620
661
  // while later awaits (interruptCurrentRun / markQueueCardExecuting's card
621
662
  // send) are in flight, so the target must be read synchronously at entry.
@@ -751,13 +792,11 @@ export class CommandRouter {
751
792
  * Handle queue.diagnose: show diagnostic info for why message is queuing.
752
793
  */
753
794
  async handleQueueDiagnose(value, ctx) {
754
- const workspace = value.workspace;
755
- const messageId = value.messageId;
756
- const targetUserId = value.userId ?? ctx.userId;
757
- if (!workspace || !messageId) {
758
- await this.bridge.sendResult({ text: '卡片 payload 缺少必要信息' }, ctx);
795
+ const payload = await this.queuePayloadOrReply(value, ctx);
796
+ if (!payload)
759
797
  return;
760
- }
798
+ const { workspace, messageId } = payload;
799
+ const targetUserId = value.userId ?? ctx.userId;
761
800
  const task = this.bridge.getQueuedTask(workspace, messageId);
762
801
  const queueInfo = this.bridge.getQueueInfo(workspace);
763
802
  const activeRuns = this.bridge.getAllActiveRuns();
@@ -825,12 +864,10 @@ export class CommandRouter {
825
864
  * Handle queue.edit: show input field for editing the queued message.
826
865
  */
827
866
  async handleQueueEdit(value, ctx) {
828
- const workspace = value.workspace;
829
- const messageId = value.messageId;
830
- if (!workspace || !messageId) {
831
- await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
867
+ const payload = await this.queuePayloadOrReply(value, ctx);
868
+ if (!payload)
832
869
  return;
833
- }
870
+ const { workspace, messageId } = payload;
834
871
  // Get current message preview
835
872
  const task = this.bridge.getQueuedTask(workspace, messageId);
836
873
  if (!task) {
@@ -896,13 +933,11 @@ export class CommandRouter {
896
933
  * when the callback response has no `card` field).
897
934
  */
898
935
  async handleQueueInput(value, ctx) {
899
- const workspace = value.workspace;
900
- const messageId = value.messageId;
901
936
  const newMessage = value.inputValue ?? value.formValue?.['newMessage'];
902
- if (!workspace || !messageId) {
903
- await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
937
+ const payload = await this.queuePayloadOrReply(value, ctx);
938
+ if (!payload)
904
939
  return;
905
- }
940
+ const { workspace, messageId } = payload;
906
941
  if (!newMessage) {
907
942
  await this.bridge.sendResult({ text: '⚠️ 缺少新消息内容' }, ctx);
908
943
  return;
@@ -963,6 +998,21 @@ export class CommandRouter {
963
998
  card: { type: 'raw', data: card },
964
999
  };
965
1000
  }
1001
+ /**
1002
+ * 分页卡动作的 offset 解析:优先读取「跳转页码」输入框(paginationBar 内置
1003
+ * 的 CardKit 2.0 input),未输入则回退 `payload.offset`(上一页/下一页按钮)。
1004
+ * 非法页码返回 `{ error }`,调用方应回错误 toast 且不刷新卡片。
1005
+ */
1006
+ pagingOffset(value, defaultPageSize) {
1007
+ const rawSize = Number(value.pageSize);
1008
+ const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? Math.trunc(rawSize) : defaultPageSize;
1009
+ const jump = parseJumpOffset(value.inputValue, pageSize);
1010
+ if (jump === null)
1011
+ return { error: PAGE_JUMP_INVALID_HINT };
1012
+ if (jump !== undefined)
1013
+ return { offset: jump };
1014
+ return { offset: payloadOffset(value) };
1015
+ }
966
1016
  /**
967
1017
  * Handle ls.switch: switch cwd to target directory after path validation.
968
1018
  */
@@ -996,8 +1046,10 @@ export class CommandRouter {
996
1046
  }
997
1047
  // Set cwd + auto-resume + user notification (shared with /cd and /ws use)
998
1048
  const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
999
- // Card channel: refresh ls card in-place (existing behavior preserved)
1000
- const card = this.cmdLs([canonical], ctx, 0);
1049
+ // Card channel: refresh ls card in-place (existing behavior preserved)。
1050
+ // value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
1051
+ // 「返回」仍回到用户最初 /ls 的那个目录。
1052
+ const card = this.cmdLs([canonical], ctx, 0, value.root);
1001
1053
  await this.bridge.updateCardInPlace(card.card, ctx);
1002
1054
  // Message channel: send switch confirmation / auto-resume card to chat
1003
1055
  await this.bridge.sendResult(notifyResult, ctx);
@@ -1017,11 +1069,11 @@ export class CommandRouter {
1017
1069
  try {
1018
1070
  card =
1019
1071
  targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
1020
- ? this.cmdLs([targetPath], ctx, offset)
1021
- : this.cmdLs([], ctx, offset);
1072
+ ? this.cmdLs([targetPath], ctx, offset, value.root)
1073
+ : this.cmdLs([], ctx, offset, value.root);
1022
1074
  }
1023
1075
  catch {
1024
- card = this.cmdLs([], ctx, offset);
1076
+ card = this.cmdLs([], ctx, offset, value.root);
1025
1077
  }
1026
1078
  await this.bridge.updateCardInPlace(card.card, ctx);
1027
1079
  }
@@ -1030,10 +1082,13 @@ export class CommandRouter {
1030
1082
  */
1031
1083
  async handleLsPage(value, ctx) {
1032
1084
  const targetPath = value.path;
1033
- const offset = value.offset ?? 0;
1034
1085
  if (!targetPath) {
1035
1086
  return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
1036
1087
  }
1088
+ const paging = this.pagingOffset(value, CommandRouter.LS_PAGE_SIZE);
1089
+ if ('error' in paging) {
1090
+ return { toast: { type: 'error', content: paging.error } };
1091
+ }
1037
1092
  const resolvedTarget = path.resolve(targetPath);
1038
1093
  // TOCTOU guard (review P2-2): existsSync → statSync can race; without the
1039
1094
  // try/catch the throw lands in enqueueImmediate's silent .catch and the
@@ -1048,7 +1103,7 @@ export class CommandRouter {
1048
1103
  if (!isDir) {
1049
1104
  return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
1050
1105
  }
1051
- const card = this.cmdLs([resolvedTarget], ctx, offset);
1106
+ const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root);
1052
1107
  // 直接更新卡片,与 ls.browse/ls.refresh 行为一致
1053
1108
  await this.bridge.updateCardInPlace(card.card, ctx);
1054
1109
  return { toast: { type: 'success', content: '' } };
@@ -1063,7 +1118,10 @@ export class CommandRouter {
1063
1118
  const resolvedAgent = validAgents.includes(resumeAgent)
1064
1119
  ? resumeAgent
1065
1120
  : this.config.defaultAgent;
1066
- const { pageSize, alignedOffset } = normalizePageArgs(value, RESUME_PAGE_SIZE);
1121
+ const { pageSize, alignedOffset, error } = normalizePageArgs(value, RESUME_PAGE_SIZE);
1122
+ if (error) {
1123
+ return { toast: { type: 'error', content: error } };
1124
+ }
1067
1125
  const entry = this.sessionStore.get(ctx.userId);
1068
1126
  const cwd = entry?.cwd;
1069
1127
  if (!cwd) {
@@ -1090,7 +1148,10 @@ export class CommandRouter {
1090
1148
  * Mirrors handleResumePage — updates the same card, never sends a new one.
1091
1149
  */
1092
1150
  async handleActivePage(value, ctx) {
1093
- const { alignedOffset } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
1151
+ const { alignedOffset, error } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
1152
+ if (error) {
1153
+ return { toast: { type: 'error', content: error } };
1154
+ }
1094
1155
  const result = this.cmdActive([], ctx, alignedOffset);
1095
1156
  if (!result.card) {
1096
1157
  return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
@@ -1113,7 +1174,7 @@ export class CommandRouter {
1113
1174
  return;
1114
1175
  }
1115
1176
  // Always reset to page 0 when browsing to a new directory
1116
- const card = this.cmdLs([resolvedTarget], ctx, 0);
1177
+ const card = this.cmdLs([resolvedTarget], ctx, 0, value.root);
1117
1178
  await this.bridge.updateCardInPlace(card.card, ctx);
1118
1179
  }
1119
1180
  /**
@@ -1167,13 +1228,58 @@ export class CommandRouter {
1167
1228
  internalKey: `order-${order.id}-${this.orderExecKeyCounter++}`,
1168
1229
  };
1169
1230
  }
1231
+ /**
1232
+ * W2.8 单源:重建 order/ws 列表卡(不投递,card 进 callback 响应体用)。
1233
+ */
1234
+ rebuildListCard(kind, offset, ctx) {
1235
+ return kind === 'order'
1236
+ ? this.cmdOrder([], ctx, offset).card
1237
+ : this.cmdWs([], ctx, offset).card;
1238
+ }
1239
+ /**
1240
+ * W2.8 单源:「重建列表卡 → updateCardInPlace」原地刷新骨架(列表卡删除/
1241
+ * 翻页/移除/排序动作共用)。返回重建后的卡片;无卡时返回 undefined 且跳过更新。
1242
+ */
1243
+ async refreshListCard(kind, value, ctx) {
1244
+ const card = this.rebuildListCard(kind, payloadOffset(value), ctx);
1245
+ if (!card)
1246
+ return undefined;
1247
+ await this.bridge.updateCardInPlace(card, ctx);
1248
+ return card;
1249
+ }
1250
+ /**
1251
+ * W2.8 单源:order 三个 input handler(aliasInput/aliasRemove/textInput)
1252
+ * 的公共骨架——校验 orderId → reload+find → mutate → 成功后 toast + raw 卡。
1253
+ * mutate 返回 error 时仅回 error toast,不重渲染。
1254
+ * 注意:本 helper 只服务「toast + card 进响应体」语义(飞书需用响应体原地
1255
+ * 替换 pre-click 编辑卡);updateCardInPlace 原地刷新走 refreshListCard。
1256
+ */
1257
+ async mutateOrderAndRefreshCard(value, ctx, opts, mutate) {
1258
+ if (!value.orderId) {
1259
+ return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1260
+ }
1261
+ this.orderStore.reload();
1262
+ const order = this.orderStore.get().find((o) => o.id === value.orderId);
1263
+ if (!order) {
1264
+ return { toast: { type: 'error', content: opts.missingToast } };
1265
+ }
1266
+ const outcome = mutate(order);
1267
+ if (!outcome.ok) {
1268
+ return { toast: { type: 'error', content: outcome.error } };
1269
+ }
1270
+ const result = this.cmdOrder([], ctx, payloadOffset(value));
1271
+ return {
1272
+ toast: { type: 'success', content: outcome.toast },
1273
+ card: { type: 'raw', data: result.card },
1274
+ };
1275
+ }
1170
1276
  /**
1171
1277
  * Handle order.delete: remove the order and update card in place.
1172
1278
  */
1173
1279
  async handleOrderDelete(value, ctx) {
1174
1280
  const orderId = value.orderId;
1175
1281
  if (!orderId) {
1176
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1282
+ return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1177
1283
  }
1178
1284
  if (!this.orderStore.has(orderId)) {
1179
1285
  return { toast: { type: 'error', content: '指令不存在或已被删除' } };
@@ -1181,9 +1287,7 @@ export class CommandRouter {
1181
1287
  // Delete the order
1182
1288
  this.orderStore.remove(orderId);
1183
1289
  // Refresh the order list and update card in place, preserving current page
1184
- const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1185
- const result = this.cmdOrder([], ctx, currentOffset);
1186
- await this.bridge.updateCardInPlace(result.card, ctx);
1290
+ await this.refreshListCard('order', value, ctx);
1187
1291
  return { toast: { type: 'success', content: '已删除指令' } };
1188
1292
  }
1189
1293
  /**
@@ -1191,27 +1295,34 @@ export class CommandRouter {
1191
1295
  * Mirrors handleLsPage — updates the same card, never sends a new one.
1192
1296
  */
1193
1297
  async handleOrderPage(value, ctx) {
1194
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1298
+ const paging = this.pagingOffset(value, ORDER_PAGE_SIZE);
1299
+ if ('error' in paging) {
1300
+ return { toast: { type: 'error', content: paging.error } };
1301
+ }
1195
1302
  // cmdOrder internally clamps stale/out-of-range offsets
1196
- const result = this.cmdOrder([], ctx, offset);
1197
- await this.bridge.updateCardInPlace(result.card, ctx);
1303
+ await this.refreshListCard('order', { offset: paging.offset }, ctx);
1198
1304
  return { toast: { type: 'success', content: '' } };
1199
1305
  }
1200
1306
  /**
1201
1307
  * Handle order.aliasEdit: 展示别名编辑卡(input + ✓ 提交图标)。
1202
1308
  * 有别名时预填;提交 `order.aliasInput` 处理(含校验与唯一性)。
1203
1309
  */
1204
- async handleOrderAliasEdit(value, ctx) {
1310
+ /**
1311
+ * order 编辑卡公共骨架:payload 校验 → 查指令 → 构造 input 编辑卡 → 原地更新。
1312
+ * handleOrderAliasEdit / handleOrderTextEdit 原先各持一份 ~90% 相同的 4 段结构。
1313
+ */
1314
+ async showOrderEditCard(value, ctx, opts) {
1205
1315
  const orderId = value.orderId;
1206
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1316
+ const offset = payloadOffset(value);
1207
1317
  if (!orderId) {
1208
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1318
+ return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1209
1319
  }
1210
1320
  this.orderStore.reload();
1211
1321
  const order = this.orderStore.get().find((o) => o.id === orderId);
1212
1322
  if (!order) {
1213
1323
  return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1214
1324
  }
1325
+ // 列表卡显示的是截断版(≤100 字符 + ...),编辑卡预览保持一致
1215
1326
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
1216
1327
  // CardKit 2.0 input 自带 ✓ 提交图标(input_value 经 raw 回传,红线)。
1217
1328
  // 不用 form 容器(触发 300123 无 submit button / 200621 嵌套 column)。
@@ -1220,7 +1331,7 @@ export class CommandRouter {
1220
1331
  config: { wide_screen_mode: true },
1221
1332
  header: {
1222
1333
  template: 'blue',
1223
- title: { tag: 'plain_text', content: '✏️ 给指令起别名' },
1334
+ title: { tag: 'plain_text', content: opts.title },
1224
1335
  },
1225
1336
  body: {
1226
1337
  elements: [
@@ -1231,10 +1342,7 @@ export class CommandRouter {
1231
1342
  { tag: 'hr' },
1232
1343
  {
1233
1344
  tag: 'div',
1234
- text: {
1235
- tag: 'lark_md',
1236
- content: '💡 输入别名名后点击右侧 ✓ 提交;留空提交 = 删除该别名',
1237
- },
1345
+ text: { tag: 'lark_md', content: opts.hint },
1238
1346
  },
1239
1347
  {
1240
1348
  tag: 'column_set',
@@ -1246,13 +1354,14 @@ export class CommandRouter {
1246
1354
  elements: [
1247
1355
  {
1248
1356
  tag: 'input',
1249
- name: 'aliasName',
1250
- placeholder: { tag: 'plain_text', content: '输入 $别名名(如 all)...' },
1251
- default_value: order.alias ?? '',
1357
+ name: opts.inputName,
1358
+ placeholder: { tag: 'plain_text', content: opts.placeholder },
1359
+ default_value: opts.defaultValue,
1360
+ ...(opts.maxLength !== undefined ? { max_length: opts.maxLength } : {}),
1252
1361
  behaviors: [
1253
1362
  {
1254
1363
  type: 'callback',
1255
- value: { cmd: 'order.aliasInput', orderId, offset },
1364
+ value: { cmd: opts.inputCmd, orderId, offset },
1256
1365
  },
1257
1366
  ],
1258
1367
  },
@@ -1265,67 +1374,53 @@ export class CommandRouter {
1265
1374
  };
1266
1375
  await this.bridge.updateCardInPlace(editCard, ctx);
1267
1376
  }
1377
+ async handleOrderAliasEdit(value, ctx) {
1378
+ this.orderStore.reload();
1379
+ const order = this.orderStore.get().find((o) => o.id === value.orderId);
1380
+ return this.showOrderEditCard(value, ctx, {
1381
+ title: '✏️ 给指令起别名',
1382
+ hint: '💡 输入别名名后点击右侧 ✓ 提交;留空提交 = 删除该别名',
1383
+ inputName: 'aliasName',
1384
+ placeholder: '输入 $别名名(如 all)...',
1385
+ defaultValue: order?.alias ?? '',
1386
+ inputCmd: 'order.aliasInput',
1387
+ });
1388
+ }
1268
1389
  /**
1269
1390
  * Handle order.aliasInput: 提交别名(绑/改/解绑),随后重渲染 /order 列表卡。
1270
1391
  * 留空 = 解绑;非空按命名规则 + 全局唯一校验,失败仅 toast 不重渲染。
1271
1392
  */
1272
1393
  async handleOrderAliasInput(value, ctx) {
1273
- const orderId = value.orderId;
1274
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1275
1394
  const name = value.inputValue ?? value.formValue?.['aliasName'];
1276
- if (!orderId) {
1277
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1278
- }
1279
- this.orderStore.reload();
1280
- const order = this.orderStore.get().find((o) => o.id === orderId);
1281
- if (!order) {
1282
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1283
- }
1284
- const trimmed = name?.trim() ?? '';
1285
- // 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
1286
- // 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
1287
- const prevAlias = order.alias;
1288
- try {
1289
- this.orderStore.setAlias(order.id, trimmed === '' ? undefined : trimmed);
1290
- }
1291
- catch (err) {
1292
- return { toast: { type: 'error', content: err.message } };
1293
- }
1294
- // 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
1295
- // 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
1296
- // 点击前那张卡),见 handleQueueInput 同款注释。
1297
- const result = this.cmdOrder([], ctx, offset);
1298
- return {
1299
- toast: {
1300
- type: 'success',
1301
- content: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
1302
- },
1303
- card: { type: 'raw', data: result.card },
1304
- };
1395
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
1396
+ const trimmed = name?.trim() ?? '';
1397
+ // 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
1398
+ // 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
1399
+ const prevAlias = order.alias;
1400
+ try {
1401
+ this.orderStore.setAlias(order.id, trimmed === '' ? undefined : trimmed);
1402
+ }
1403
+ catch (err) {
1404
+ return { ok: false, error: err.message };
1405
+ }
1406
+ return {
1407
+ ok: true,
1408
+ toast: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
1409
+ };
1410
+ });
1305
1411
  }
1306
1412
  /** Handle order.aliasRemove: 删除某条指令的别名并原地重渲染列表卡。 */
1307
1413
  async handleOrderAliasRemove(value, ctx) {
1308
- const orderId = value.orderId;
1309
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1310
- if (!orderId) {
1311
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1312
- }
1313
- this.orderStore.reload();
1314
- const order = this.orderStore.get().find((o) => o.id === orderId);
1315
- if (!order || !order.alias) {
1316
- return { toast: { type: 'error', content: '指令不存在或没有别名' } };
1317
- }
1318
- // 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
1319
- // 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
1320
- const removedAlias = order.alias;
1321
- this.orderStore.setAlias(order.id, undefined);
1322
- // 必须把 card 放进 callback 响应体让飞书原地替换 pre-click 卡片,否则删除
1323
- // 后卡片停留在旧状态(别名视觉上未消失)。同 handleQueueInput 语义。
1324
- const result = this.cmdOrder([], ctx, offset);
1325
- return {
1326
- toast: { type: 'success', content: `✅ 已移除别名 $${removedAlias}` },
1327
- card: { type: 'raw', data: result.card },
1328
- };
1414
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或没有别名' }, (order) => {
1415
+ if (!order.alias) {
1416
+ return { ok: false, error: '指令不存在或没有别名' };
1417
+ }
1418
+ // 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
1419
+ // 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
1420
+ const removedAlias = order.alias;
1421
+ this.orderStore.setAlias(order.id, undefined);
1422
+ return { ok: true, toast: `✅ 已移除别名 $${removedAlias}` };
1423
+ });
1329
1424
  }
1330
1425
  /**
1331
1426
  * Handle order.textEdit: 展示指令文本编辑卡(input + ✓ 提交图标)。
@@ -1335,73 +1430,20 @@ export class CommandRouter {
1335
1430
  * updateCardInPlace)。
1336
1431
  */
1337
1432
  async handleOrderTextEdit(value, ctx) {
1338
- const orderId = value.orderId;
1339
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1340
- if (!orderId) {
1341
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1342
- }
1343
1433
  this.orderStore.reload();
1344
- const order = this.orderStore.get().find((o) => o.id === orderId);
1345
- if (!order) {
1346
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1347
- }
1348
- // 列表卡显示的是截断版(≤100 字符 + ...),编辑卡预览也保持一致;
1349
- // input 组件 default_value 预填完整 text(不受截断影响),用户提交后生效完整文本。
1350
- const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
1351
- // CardKit 2.0 input 自带 ✓ 提交图标(input_value 经 raw 回传,红线)。
1352
- // 不用 form 容器(触发 300123 无 submit button / 200621 嵌套 column)。
1353
- const editCard = {
1354
- schema: '2.0',
1355
- config: { wide_screen_mode: true },
1356
- header: {
1357
- template: 'blue',
1358
- title: { tag: 'plain_text', content: '✏️ 编辑指令' },
1359
- },
1360
- body: {
1361
- elements: [
1362
- {
1363
- tag: 'div',
1364
- text: { tag: 'lark_md', content: `**当前指令:**\n\`${displayText}\`` },
1365
- },
1366
- { tag: 'hr' },
1367
- {
1368
- tag: 'div',
1369
- text: {
1370
- tag: 'lark_md',
1371
- content: '💡 修改后点击右侧 ✓ 提交;空白提交 = 报错;最多 200 字符',
1372
- },
1373
- },
1374
- {
1375
- tag: 'column_set',
1376
- columns: [
1377
- {
1378
- tag: 'column',
1379
- width: 'weighted',
1380
- weight: 3,
1381
- elements: [
1382
- {
1383
- tag: 'input',
1384
- name: 'text',
1385
- placeholder: { tag: 'plain_text', content: '输入新的指令文本...' },
1386
- default_value: order.text,
1387
- // max_length 与 MAX_TEXT_LENGTH=200 对齐:超长在输入侧直接截断,
1388
- // 避免提交后才报错(后端 updateText 仍会二次校验,双保险)。
1389
- max_length: 200,
1390
- behaviors: [
1391
- {
1392
- type: 'callback',
1393
- value: { cmd: 'order.textInput', orderId, offset },
1394
- },
1395
- ],
1396
- },
1397
- ],
1398
- },
1399
- ],
1400
- },
1401
- ],
1402
- },
1403
- };
1404
- await this.bridge.updateCardInPlace(editCard, ctx);
1434
+ const order = this.orderStore.get().find((o) => o.id === value.orderId);
1435
+ // input 组件 default_value 预填完整 text(不受截断影响),提交后生效完整文本
1436
+ return this.showOrderEditCard(value, ctx, {
1437
+ title: '✏️ 编辑指令',
1438
+ hint: '💡 修改后点击右侧 ✓ 提交;空白提交 = 报错;最多 200 字符',
1439
+ inputName: 'text',
1440
+ placeholder: '输入新的指令文本...',
1441
+ defaultValue: order?.text ?? '',
1442
+ inputCmd: 'order.textInput',
1443
+ // 与 MAX_TEXT_LENGTH=200 对齐:超长在输入侧直接截断,避免提交后才报错
1444
+ // (后端 updateText 仍会二次校验,双保险)
1445
+ maxLength: 200,
1446
+ });
1405
1447
  }
1406
1448
  /**
1407
1449
  * Handle order.textInput: 提交指令文本编辑。校验 orderId → OrderStore.updateText
@@ -1411,37 +1453,22 @@ export class CommandRouter {
1411
1453
  * 保留 usedAt / alias / createdAt(OrderStore.updateText 只动 text 字段)。
1412
1454
  */
1413
1455
  async handleOrderTextInput(value, ctx) {
1414
- const orderId = value.orderId;
1415
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1416
1456
  const raw = value.inputValue ?? value.formValue?.['text'];
1417
- if (!orderId) {
1418
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1419
- }
1420
- this.orderStore.reload();
1421
- const order = this.orderStore.get().find((o) => o.id === orderId);
1422
- if (!order) {
1423
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1424
- }
1425
- let updated;
1426
- try {
1427
- updated = this.orderStore.updateText(order.id, raw ?? '');
1428
- }
1429
- catch (err) {
1430
- return { toast: { type: 'error', content: err.message } };
1431
- }
1432
- if (!updated) {
1433
- // reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
1434
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1435
- }
1436
- // 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
1437
- // 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
1438
- // 点击前那张卡),见 handleQueueInput / handleOrderAliasInput 同款注释。
1439
- const result = this.cmdOrder([], ctx, offset);
1440
- const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
1441
- return {
1442
- toast: { type: 'success', content: `✅ 已更新指令: ${preview}` },
1443
- card: { type: 'raw', data: result.card },
1444
- };
1457
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
1458
+ let updated;
1459
+ try {
1460
+ updated = this.orderStore.updateText(order.id, raw ?? '');
1461
+ }
1462
+ catch (err) {
1463
+ return { ok: false, error: err.message };
1464
+ }
1465
+ if (!updated) {
1466
+ // reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
1467
+ return { ok: false, error: '指令不存在或已被删除' };
1468
+ }
1469
+ const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
1470
+ return { ok: true, toast: `✅ 已更新指令: ${preview}` };
1471
+ });
1445
1472
  }
1446
1473
  /**
1447
1474
  * Handle ws.use card action: switch to the workspace, then refresh the /ws
@@ -1473,11 +1500,7 @@ export class CommandRouter {
1473
1500
  }
1474
1501
  }
1475
1502
  // Refresh the /ws list card in place so "recent" sort is immediately visible
1476
- const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1477
- const refreshed = this.cmdWs([], ctx, currentOffset);
1478
- if (refreshed.card) {
1479
- await this.bridge.updateCardInPlace(refreshed.card, ctx);
1480
- }
1503
+ await this.refreshListCard('ws', value, ctx);
1481
1504
  // Toast feedback (suppress text when a persistent message was already sent)
1482
1505
  return {
1483
1506
  toast: {
@@ -1494,16 +1517,14 @@ export class CommandRouter {
1494
1517
  async handleWsRemove(value, ctx) {
1495
1518
  const name = value.name;
1496
1519
  if (!name) {
1497
- return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1520
+ return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1498
1521
  }
1499
1522
  // Execute the removal (consumes the write-side effect; the returned text
1500
1523
  // is discarded — feedback flows through the toast + refreshed card).
1501
1524
  this.cmdWs(['remove', name], ctx);
1502
1525
  // Rebuild the /ws list card and update it in place, preserving the page
1503
1526
  // the user was on when they clicked 删除.
1504
- const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1505
- const refreshed = this.cmdWs([], ctx, currentOffset);
1506
- await this.bridge.updateCardInPlace(refreshed.card, ctx);
1527
+ await this.refreshListCard('ws', value, ctx);
1507
1528
  return { toast: { type: 'success', content: `已删除 workspace "${name}"` } };
1508
1529
  }
1509
1530
  /**
@@ -1511,10 +1532,12 @@ export class CommandRouter {
1511
1532
  * Mirrors handleOrderPage — updates the same card, never sends a new one.
1512
1533
  */
1513
1534
  async handleWsPage(value, ctx) {
1514
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1535
+ const paging = this.pagingOffset(value, WS_PAGE_SIZE);
1536
+ if ('error' in paging) {
1537
+ return { toast: { type: 'error', content: paging.error } };
1538
+ }
1515
1539
  // cmdWs internally clamps stale/out-of-range offsets
1516
- const result = this.cmdWs([], ctx, offset);
1517
- await this.bridge.updateCardInPlace(result.card, ctx);
1540
+ await this.refreshListCard('ws', { offset: paging.offset }, ctx);
1518
1541
  return { toast: { type: 'success', content: '' } };
1519
1542
  }
1520
1543
  /**
@@ -1526,8 +1549,7 @@ export class CommandRouter {
1526
1549
  const current = this.wsSortPreference.get(userId) ?? 'recent';
1527
1550
  const next = current === 'recent' ? 'alpha' : 'recent';
1528
1551
  this.wsSortPreference.set(userId, next);
1529
- const result = this.cmdWs([], ctx, 0);
1530
- await this.bridge.updateCardInPlace(result.card, ctx);
1552
+ await this.refreshListCard('ws', { offset: 0 }, ctx);
1531
1553
  const nextLabel = next === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
1532
1554
  return {
1533
1555
  toast: { type: 'success', content: `已切换为 ${nextLabel}` },
@@ -1541,26 +1563,42 @@ export class CommandRouter {
1541
1563
  await this.bridge.sendResult({ text: '卡片 payload 缺少 path' }, ctx);
1542
1564
  return;
1543
1565
  }
1544
- const resolvedTarget = path.resolve(target);
1545
- if (!fs.existsSync(resolvedTarget)) {
1546
- await this.bridge.sendResult({ text: `文件不存在: ${resolvedTarget}` }, ctx);
1547
- return;
1566
+ const result = await this.deliverFileToFeishu(path.resolve(target), ctx);
1567
+ if (result)
1568
+ await this.bridge.sendResult(result, ctx);
1569
+ }
1570
+ /**
1571
+ * 校验并发送本地文件到飞书(≤30MB)。成功返回 null(文件已发出),失败返回
1572
+ * 错误 CommandResult 由调用方展示。
1573
+ *
1574
+ * `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
1575
+ */
1576
+ async deliverFileToFeishu(resolved, ctx) {
1577
+ if (!fs.existsSync(resolved)) {
1578
+ return { text: `文件不存在: ${resolved}` };
1548
1579
  }
1549
- // Check it's a file, not directory
1550
- if (!fs.statSync(resolvedTarget).isFile()) {
1551
- await this.bridge.sendResult({ text: `不是文件: ${resolvedTarget}` }, ctx);
1552
- return;
1580
+ let stat;
1581
+ try {
1582
+ stat = fs.statSync(resolved);
1583
+ }
1584
+ catch {
1585
+ return { text: `无法读取: ${resolved}` };
1586
+ }
1587
+ if (stat.isDirectory()) {
1588
+ return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
1553
1589
  }
1554
- // Check file size (30MB, aligned with Feishu im/v1/files API)
1555
- const stat = fs.statSync(resolvedTarget);
1590
+ if (!stat.isFile()) {
1591
+ return { text: `不是普通文件: ${resolved}` };
1592
+ }
1593
+ // 30MB,与飞书 im/v1/files API 对齐
1556
1594
  if (stat.size > MAX_FILE_UPLOAD_SIZE) {
1557
1595
  const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
1558
- await this.bridge.sendResult({
1596
+ return {
1559
1597
  text: `文件太大 (${sizeMB}MB),超过 ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB 限制不能发送`,
1560
- }, ctx);
1561
- return;
1598
+ };
1562
1599
  }
1563
- await this.bridge.sendFile(resolvedTarget, ctx);
1600
+ await this.bridge.sendFile(resolved, ctx);
1601
+ return null;
1564
1602
  }
1565
1603
  // --- PendingConfig 辅助方法 ---
1566
1604
  /** 确保 pendingConfig 已初始化(从当前 config 克隆) */
@@ -1599,6 +1637,8 @@ export class CommandRouter {
1599
1637
  async dispatchConfigAction(value, ctx) {
1600
1638
  switch (value.cmd) {
1601
1639
  case 'config.toggle': {
1640
+ // 通用 boolean 翻转:目前无卡片字段使用(output.show* 已移除),
1641
+ // 保留供未来 boolean 配置项复用;configActionQueue 串行化覆盖连击。
1602
1642
  const key = value.key;
1603
1643
  if (!key) {
1604
1644
  await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
@@ -1728,68 +1768,10 @@ export class CommandRouter {
1728
1768
  /** 设置嵌套属性值(仅修改内存对象,不写盘) */
1729
1769
  /** 在 pendingConfig 上按 dot-separated key 设置嵌套值(config 卡片编辑用)。 */
1730
1770
  setNestedValue(target, key, value) {
1731
- // Path mapping 共用 config 模块的 mapAgentKey(G11 Inconsistency 修复):
1732
- // pi.xxx/codex.xxx/opencode.xxx → agents.xxx,claude.xxx 保持顶层
1733
- const mappedKey = mapAgentKey(key);
1734
- const parts = mappedKey.split('.');
1735
- // 复用 config 模块的 walkNestedContainer(含 __proto__/prototype/constructor
1736
- // 守卫与中间段补 {}),router 只保留 value=undefined → delete 分支。
1737
- const container = walkNestedContainer(target, parts, true);
1738
- const lastPart = parts[parts.length - 1];
1739
- assertSafeKeyPart(lastPart);
1740
- if (value === undefined) {
1741
- // value=undefined 表示"删除键"(如清空 reasoningEffort),
1742
- // 不能写成 undefined 值——diffConfig 会把 undefined 转成字面量 "undefined"
1743
- // 写入 config.yaml 并透传给 codex(ReasoningEffort::Custom("undefined"))。
1744
- delete container[lastPart];
1745
- }
1746
- else {
1747
- container[lastPart] = value;
1748
- }
1771
+ setNestedValue(target, key, value);
1749
1772
  }
1750
- /** 对比原始 config 与 pendingConfig,返回变化的 key→string 映射 */
1751
- /** config diff(public:测试直接调用,替代 as unknown as)。 */
1752
1773
  diffConfig(original, pending) {
1753
- const updates = {};
1754
- this.collectDiff('', original, pending, updates);
1755
- return updates;
1756
- }
1757
- /** 递归收集差异 */
1758
- collectDiff(prefix, original, pending, result) {
1759
- if (original === pending)
1760
- return;
1761
- // 如果 pending 是对象(非 null)但 original 不是对象,
1762
- // 把 original 当作空对象递归进入 pending 内部,
1763
- // 避免把整个对象转成 "[object Object]" 字符串
1764
- if (typeof pending === 'object' &&
1765
- pending !== null &&
1766
- (typeof original !== 'object' || original === null)) {
1767
- const pendObj = pending;
1768
- for (const k of Object.keys(pendObj)) {
1769
- const newKey = prefix ? `${prefix}.${k}` : k;
1770
- this.collectDiff(newKey, undefined, pendObj[k], result);
1771
- }
1772
- return;
1773
- }
1774
- if (typeof original !== 'object' ||
1775
- typeof pending !== 'object' ||
1776
- original === null ||
1777
- pending === null) {
1778
- // 叶子节点,记录差异
1779
- const key = prefix || 'root';
1780
- // pending 为 undefined 表示键被删除,必须保留 undefined 语义,
1781
- // 不能 String(pending) 成 "undefined"
1782
- result[key] = pending === undefined ? undefined : String(pending);
1783
- return;
1784
- }
1785
- // 两者都是对象,递归比较
1786
- const origObj = original;
1787
- const pendObj = pending;
1788
- const allKeys = new Set([...Object.keys(origObj), ...Object.keys(pendObj)]);
1789
- for (const k of allKeys) {
1790
- const newKey = prefix ? `${prefix}.${k}` : k;
1791
- this.collectDiff(newKey, origObj[k], pendObj[k], result);
1792
- }
1774
+ return diffConfig(original, pending);
1793
1775
  }
1794
1776
  /**
1795
1777
  * config 写盘后的运行时传播(P1-6:自 config.save 卡片路径抽出,卡片保存与
@@ -1971,6 +1953,9 @@ export class CommandRouter {
1971
1953
  return this.cmdCd(args, ctx);
1972
1954
  case 'ls':
1973
1955
  return this.cmdLs(args, ctx);
1956
+ case 'download':
1957
+ case 'd':
1958
+ return await this.cmdDownload(args, ctx);
1974
1959
  case 'ws':
1975
1960
  return this.cmdWs(args, ctx);
1976
1961
  case 'resume':
@@ -2015,7 +2000,16 @@ export class CommandRouter {
2015
2000
  // 文本组(需带参数,不宜用按钮;以纯文本行展示)
2016
2001
  const textCommands = [
2017
2002
  { cmd: 'cd', label: '/cd <path>', desc: '切换工作目录' },
2018
- { cmd: 'ls', label: '/ls [dir]', desc: '列出当前目录,可指定子目录' },
2003
+ {
2004
+ cmd: 'ls',
2005
+ label: '/ls [dir|file]',
2006
+ desc: '列出当前目录(可指定子目录);指定文件时列出该文件',
2007
+ },
2008
+ {
2009
+ cmd: 'download',
2010
+ label: '/download /d <path>',
2011
+ desc: '直接下载(发送)指定文件到飞书,上限 30MB',
2012
+ },
2019
2013
  { cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
2020
2014
  {
2021
2015
  cmd: 'order',
@@ -2515,8 +2509,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2515
2509
  return actions;
2516
2510
  }
2517
2511
  static LS_PAGE_SIZE = 30;
2518
- /** /ls 实现(public:测试直接调用,替代 as unknown as)。 */
2519
- cmdLs(args, ctx, offset = 0) {
2512
+ /**
2513
+ * `/ls` 实现(public:测试直接调用,替代 as unknown as)。
2514
+ *
2515
+ * @param args [] 列出 cwd;[path] 列出指定目录(或文件)
2516
+ * @param offset 分页起点(条目数)
2517
+ * @param rootDir 浏览起点:卡片「返回」按钮回到这里。省略时默认 = 本次列出的
2518
+ * 目录(`/ls <dir>` 的起点即该目录),因此「返回」不会把用户
2519
+ * 丢回 workspace cwd(用户明确反馈过)。
2520
+ */
2521
+ cmdLs(args, ctx, offset = 0, rootDir) {
2520
2522
  const cwd = this.sessionStore.getCwd(ctx.userId);
2521
2523
  if (!cwd) {
2522
2524
  return { text: '请先使用 /cd <path> 设置工作目录' };
@@ -2536,11 +2538,25 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2536
2538
  if (!fs.existsSync(potentialDir)) {
2537
2539
  return { text: `ls: ${args[0]}: No such file or directory` };
2538
2540
  }
2539
- if (!fs.statSync(potentialDir).isDirectory()) {
2540
- return { text: `ls: ${args[0]}: Not a directory` };
2541
+ let isDir;
2542
+ try {
2543
+ isDir = fs.statSync(potentialDir).isDirectory();
2544
+ }
2545
+ catch {
2546
+ // TOCTOU:existsSync 与 statSync 之间目标被删
2547
+ return { text: `ls: ${args[0]}: No such file or directory` };
2548
+ }
2549
+ if (!isDir) {
2550
+ // 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
2551
+ // 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
2552
+ return this.buildFileCard(potentialDir);
2541
2553
  }
2542
2554
  targetDir = potentialDir;
2543
2555
  }
2556
+ // 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
2557
+ // 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
2558
+ // 列出的目录,因此「返回」永远不会把用户丢回 workspace cwd。
2559
+ const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
2544
2560
  try {
2545
2561
  const entries = fs.readdirSync(targetDir, { withFileTypes: true });
2546
2562
  // Separate directories and files
@@ -2557,16 +2573,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2557
2573
  size: fs.statSync(path.join(targetDir, f.name)).size,
2558
2574
  })),
2559
2575
  ];
2560
- // Pagination calculations
2561
- const totalCount = allItems.length;
2562
- const totalPages = Math.max(1, Math.ceil(totalCount / CommandRouter.LS_PAGE_SIZE));
2563
- const currentPage = Math.floor(offset / CommandRouter.LS_PAGE_SIZE) + 1;
2564
- const pageItems = allItems.slice(offset, offset + CommandRouter.LS_PAGE_SIZE);
2565
- const hasPagination = totalCount > CommandRouter.LS_PAGE_SIZE;
2576
+ // Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
2577
+ const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(allItems, offset, CommandRouter.LS_PAGE_SIZE);
2566
2578
  // Check if we need to show parent directory button
2567
2579
  const parentDir = path.dirname(targetDir);
2568
2580
  const hasParent = parentDir !== targetDir;
2581
+ // 「切换」相对 cwd(把工作目录切到当前浏览目录)
2569
2582
  const isSubdir = targetDir !== cwd;
2583
+ // 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
2584
+ const canReturn = targetDir !== browseRoot;
2570
2585
  // Build header with parent button
2571
2586
  const headerElements = [];
2572
2587
  if (hasParent) {
@@ -2575,17 +2590,21 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2575
2590
  text: { tag: 'plain_text', content: '上级' },
2576
2591
  type: 'default',
2577
2592
  size: 'small',
2578
- behaviors: [{ type: 'callback', value: { cmd: 'ls.browse', path: parentDir } }],
2593
+ behaviors: [
2594
+ { type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: browseRoot } },
2595
+ ],
2579
2596
  });
2580
2597
  }
2581
- // Show "返回" button when viewing a subdirectory
2582
- if (isSubdir) {
2598
+ // Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
2599
+ if (canReturn) {
2583
2600
  headerElements.push({
2584
2601
  tag: 'button',
2585
2602
  text: { tag: 'plain_text', content: '返回' },
2586
2603
  type: 'default',
2587
2604
  size: 'small',
2588
- behaviors: [{ type: 'callback', value: { cmd: 'ls.browse', path: cwd } }],
2605
+ behaviors: [
2606
+ { type: 'callback', value: { cmd: 'ls.browse', path: browseRoot, root: browseRoot } },
2607
+ ],
2589
2608
  });
2590
2609
  }
2591
2610
  headerElements.push({
@@ -2593,7 +2612,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2593
2612
  text: { tag: 'plain_text', content: '刷新' },
2594
2613
  type: 'default',
2595
2614
  size: 'small',
2596
- behaviors: [{ type: 'callback', value: { cmd: 'ls.refresh', path: targetDir, offset } }],
2615
+ behaviors: [
2616
+ {
2617
+ type: 'callback',
2618
+ value: {
2619
+ cmd: 'ls.refresh',
2620
+ path: targetDir,
2621
+ offset: safeOffset,
2622
+ root: browseRoot,
2623
+ },
2624
+ },
2625
+ ],
2597
2626
  });
2598
2627
  // Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
2599
2628
  if (isSubdir) {
@@ -2602,28 +2631,32 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2602
2631
  text: { tag: 'plain_text', content: '切换' },
2603
2632
  type: 'primary',
2604
2633
  size: 'small',
2605
- behaviors: [{ type: 'callback', value: { cmd: 'ls.switch', path: targetDir } }],
2634
+ // 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
2635
+ // 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
2636
+ behaviors: [
2637
+ { type: 'callback', value: { cmd: 'ls.switch', path: targetDir, root: browseRoot } },
2638
+ ],
2606
2639
  });
2607
2640
  }
2608
- const formatSize = (bytes) => {
2609
- if (bytes < 1024)
2610
- return `${bytes}B`;
2611
- if (bytes < 1024 * 1024)
2612
- return `${(bytes / 1024).toFixed(1)}KB`;
2613
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
2614
- };
2615
2641
  // Helper to create directory button - use targetDir
2616
2642
  const dirButton = (name) => ({
2617
2643
  tag: 'button',
2618
2644
  text: { tag: 'plain_text', content: `📁 ${name}` },
2619
2645
  type: 'default',
2620
2646
  behaviors: [
2621
- { type: 'callback', value: { cmd: 'ls.browse', path: path.join(targetDir, name) } },
2647
+ {
2648
+ type: 'callback',
2649
+ value: {
2650
+ cmd: 'ls.browse',
2651
+ path: path.join(targetDir, name),
2652
+ root: browseRoot,
2653
+ },
2654
+ },
2622
2655
  ],
2623
2656
  });
2624
2657
  // Helper to create file button with size - use targetDir
2625
2658
  const fileButton = (name, size) => {
2626
- const sizeStr = size !== undefined ? ` (${formatSize(size)})` : '';
2659
+ const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
2627
2660
  return {
2628
2661
  tag: 'button',
2629
2662
  text: { tag: 'plain_text', content: `📄 ${name}${sizeStr}` },
@@ -2637,10 +2670,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2637
2670
  // Build each section's elements (no tabs - use section headers)
2638
2671
  // Note: div+elements is not supported in CardKit 2.0, use column_set instead
2639
2672
  const elements = [];
2640
- // Status line showing directory contents count (with pagination info)
2641
- const status = hasPagination
2642
- ? `\n共 ${dirs.length} 目录, ${files.length} 文件 · 第 ${currentPage}/${totalPages} 页(共 ${totalCount} 项)`
2643
- : `\n共 ${dirs.length} 目录, ${files.length} 文件`;
2673
+ // Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
2674
+ // 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。
2675
+ const status = `\n共 ${dirs.length} 目录, ${files.length} 文件`;
2644
2676
  // Header info + navigation buttons - show targetDir in header
2645
2677
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
2646
2678
  elements.push({
@@ -2701,14 +2733,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2701
2733
  if (hasPagination) {
2702
2734
  const bar = paginationBar({
2703
2735
  cmd: 'ls.page',
2704
- offset,
2736
+ offset: safeOffset,
2705
2737
  pageSize: CommandRouter.LS_PAGE_SIZE,
2706
2738
  total: totalCount,
2707
- extra: { path: targetDir },
2739
+ extra: { path: targetDir, root: browseRoot },
2708
2740
  label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
2709
2741
  });
2710
2742
  elements.push({ tag: 'hr' });
2711
- elements.push(bar);
2743
+ elements.push(...bar);
2712
2744
  }
2713
2745
  return {
2714
2746
  card: {
@@ -2728,6 +2760,99 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2728
2760
  return { text: `读取目录失败: ${err.message}` };
2729
2761
  }
2730
2762
  }
2763
+ /**
2764
+ * `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
2765
+ *
2766
+ * 路径解析与 `/ls` 一致:`~` 展开到 home;相对路径基于当前 cwd(无 cwd 时
2767
+ * 必须给绝对路径)。成功时文件本身即回复,返回 null(不额外发文本)。
2768
+ */
2769
+ async cmdDownload(args, ctx) {
2770
+ const raw = args.join(' ').trim();
2771
+ if (raw === '') {
2772
+ return { text: '用法: /download <path>(别名 /d)' };
2773
+ }
2774
+ // 路径可能含空格:args 按空白切分后再拼回(未加引号也能还原原路径)
2775
+ let input = raw;
2776
+ if (input.startsWith('~')) {
2777
+ input = path.join(os.homedir(), input.slice(1));
2778
+ }
2779
+ if (!path.isAbsolute(input)) {
2780
+ const cwd = this.sessionStore.getCwd(ctx.userId);
2781
+ if (!cwd)
2782
+ return { text: '请先使用 /cd <path> 设置工作目录' };
2783
+ input = path.resolve(cwd, input);
2784
+ }
2785
+ return this.deliverFileToFeishu(path.resolve(input), ctx);
2786
+ }
2787
+ /**
2788
+ * 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
2789
+ *
2790
+ * `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
2791
+ * 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
2792
+ */
2793
+ buildFileCard(filePath) {
2794
+ let stat;
2795
+ try {
2796
+ stat = fs.statSync(filePath);
2797
+ }
2798
+ catch {
2799
+ stat = undefined;
2800
+ }
2801
+ const sizeText = stat ? formatBytes(stat.size) : '未知';
2802
+ const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
2803
+ const parentDir = path.dirname(filePath);
2804
+ const buttons = [
2805
+ {
2806
+ tag: 'button',
2807
+ text: { tag: 'plain_text', content: '📎 下载' },
2808
+ type: 'primary',
2809
+ size: 'small',
2810
+ behaviors: [{ type: 'callback', value: { cmd: 'ls.file', path: filePath } }],
2811
+ },
2812
+ ];
2813
+ if (parentDir !== filePath) {
2814
+ buttons.push({
2815
+ tag: 'button',
2816
+ text: { tag: 'plain_text', content: '上级' },
2817
+ type: 'default',
2818
+ size: 'small',
2819
+ // 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 workspace cwd
2820
+ behaviors: [
2821
+ { type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: parentDir } },
2822
+ ],
2823
+ });
2824
+ }
2825
+ return {
2826
+ card: {
2827
+ schema: '2.0',
2828
+ config: { wide_screen_mode: true, update_multi: true },
2829
+ header: {
2830
+ title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
2831
+ template: 'blue',
2832
+ },
2833
+ body: {
2834
+ elements: [
2835
+ {
2836
+ tag: 'div',
2837
+ text: {
2838
+ tag: 'lark_md',
2839
+ content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
2840
+ },
2841
+ },
2842
+ {
2843
+ tag: 'column_set',
2844
+ columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
2845
+ },
2846
+ { tag: 'hr' },
2847
+ {
2848
+ tag: 'div',
2849
+ text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
2850
+ },
2851
+ ],
2852
+ },
2853
+ },
2854
+ };
2855
+ }
2731
2856
  cmdWs(args, ctx, offset = 0) {
2732
2857
  const sub = args[0]?.toLowerCase();
2733
2858
  switch (sub) {
@@ -2795,13 +2920,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2795
2920
  }
2796
2921
  // Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
2797
2922
  // offsets so prev/next always step by WS_PAGE_SIZE.
2798
- const totalCount = entries.length;
2799
- const totalPages = Math.max(1, Math.ceil(totalCount / WS_PAGE_SIZE));
2800
- const maxOffset = Math.max(0, (totalPages - 1) * WS_PAGE_SIZE);
2801
- const safeOffset = clampInt(offset, 0, maxOffset);
2802
- const currentPage = Math.floor(safeOffset / WS_PAGE_SIZE) + 1;
2803
- const pageEntries = entries.slice(safeOffset, safeOffset + WS_PAGE_SIZE);
2804
- const hasPagination = totalCount > WS_PAGE_SIZE;
2923
+ const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(entries, offset, WS_PAGE_SIZE);
2805
2924
  // Build body elements: current cwd + workspace list with dividers
2806
2925
  const bodyElements = [];
2807
2926
  // Current working directory section
@@ -2918,7 +3037,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2918
3037
  nextText: '➡',
2919
3038
  });
2920
3039
  bodyElements.push({ tag: 'hr' });
2921
- bodyElements.push(bar);
3040
+ bodyElements.push(...bar);
2922
3041
  }
2923
3042
  }
2924
3043
  const card = {
@@ -3214,8 +3333,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3214
3333
  }
3215
3334
  // Pagination bar (only shown when there are more sessions than pageSize)
3216
3335
  if (total > pageSize) {
3217
- const totalPages = Math.ceil(total / pageSize);
3218
- const currentPage = Math.floor(pageOffset / pageSize) + 1;
3336
+ const { totalPages, currentPage } = pageInfo(total, pageOffset, pageSize);
3219
3337
  const bar = paginationBar({
3220
3338
  cmd: 'resume.page',
3221
3339
  offset: pageOffset,
@@ -3225,7 +3343,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3225
3343
  label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
3226
3344
  });
3227
3345
  elements.push({ tag: 'hr' });
3228
- elements.push(bar);
3346
+ elements.push(...bar);
3229
3347
  }
3230
3348
  const card = {
3231
3349
  schema: '2.0',
@@ -3259,21 +3377,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3259
3377
  buildActiveCardFromMemory(activeRuns, activeBashRuns, offset = 0) {
3260
3378
  const totalCount = activeRuns.length + activeBashRuns.length;
3261
3379
  const pageSize = ACTIVE_PAGE_SIZE;
3262
- const safeOffset = Math.max(0, Math.min(offset, Math.max(0, totalCount - 1)));
3263
- const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
3264
- const currentPage = Math.floor(safeOffset / pageSize) + 1;
3380
+ // 统一 clamp 到最后一页边界(原先 clamp 到 totalCount-1,过期 offset 会
3381
+ // 泄漏成「第 1 页只显示末尾几条」的残页)
3382
+ const { totalPages, safeOffset, currentPage } = pageInfo(totalCount, offset, pageSize);
3265
3383
  // Slice: distribute offset across agent runs first, then bash runs
3266
3384
  const elements = [];
3267
- // Page info
3268
- if (totalPages > 1) {
3269
- elements.push({
3270
- tag: 'div',
3271
- text: {
3272
- tag: 'lark_md',
3273
- content: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
3274
- },
3275
- });
3276
- }
3277
3385
  let remaining = pageSize;
3278
3386
  let skipped = safeOffset;
3279
3387
  // Agent runs section
@@ -3286,7 +3394,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3286
3394
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
3287
3395
  elements.push({
3288
3396
  tag: 'div',
3289
- text: { tag: 'lark_md', content: `**📂 ${run.cwd.split('/').pop() ?? run.cwd}**` },
3397
+ text: { tag: 'lark_md', content: `**📂 ${displayName(run.cwd)}**` },
3290
3398
  });
3291
3399
  elements.push({
3292
3400
  tag: 'div',
@@ -3338,37 +3446,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3338
3446
  elements.push({ tag: 'hr' });
3339
3447
  }
3340
3448
  }
3341
- // Pagination buttons
3449
+ // Pagination bar(W2.9 复用 paginationBar:safeOffset 已对齐页边界,
3450
+ // offset±pageSize 与原先 currentPage±1 的页码换算等价)
3342
3451
  if (totalPages > 1) {
3343
- const paginationButtons = [];
3344
- if (currentPage > 1) {
3345
- const prevOffset = (currentPage - 2) * pageSize;
3346
- paginationButtons.push({
3347
- tag: 'button',
3348
- text: { tag: 'plain_text', content: '◀ 上一页' },
3349
- type: 'default',
3350
- size: 'small',
3351
- behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: prevOffset } }],
3352
- });
3353
- }
3354
- if (currentPage < totalPages) {
3355
- const nextOffset = currentPage * pageSize;
3356
- paginationButtons.push({
3357
- tag: 'button',
3358
- text: { tag: 'plain_text', content: '下一页 ▶' },
3359
- type: 'primary',
3360
- size: 'small',
3361
- behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: nextOffset } }],
3362
- });
3363
- }
3364
- elements.push({
3365
- tag: 'column_set',
3366
- columns: paginationButtons.map((btn) => ({
3367
- tag: 'column',
3368
- width: 'auto',
3369
- elements: [btn],
3370
- })),
3371
- });
3452
+ elements.push(...paginationBar({
3453
+ cmd: 'active.page',
3454
+ offset: safeOffset,
3455
+ pageSize,
3456
+ total: totalCount,
3457
+ label: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
3458
+ prevText: '◀ 上一页',
3459
+ nextText: '下一页 ▶',
3460
+ }));
3372
3461
  }
3373
3462
  const card = {
3374
3463
  schema: '2.0',
@@ -3518,15 +3607,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3518
3607
  label: '⏱️ 空闲',
3519
3608
  fields: [{ key: 'idle.watchdogMinutes', label: '空闲超时(分钟, 0关闭)', type: 'input' }],
3520
3609
  },
3521
- {
3522
- id: 'output',
3523
- label: '📤 输出',
3524
- fields: [
3525
- { key: 'output.showThinking', label: '显示思考过程', type: 'boolean' },
3526
- { key: 'output.showToolUse', label: '显示工具调用', type: 'boolean' },
3527
- { key: 'output.showToolResult', label: '显示工具结果', type: 'boolean' },
3528
- ],
3529
- },
3530
3610
  {
3531
3611
  id: 'logging',
3532
3612
  label: '📝 日志',
@@ -3576,14 +3656,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3576
3656
  }
3577
3657
  else {
3578
3658
  // Pagination calculations
3579
- const totalCount = allOrders.length;
3580
- const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
3581
- // Clamp offset to last page boundary to avoid empty pages
3582
- const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
3583
- const safeOffset = clampInt(offset, 0, maxOffset);
3584
- const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
3585
- const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
3586
- const hasPagination = totalCount > ORDER_PAGE_SIZE;
3659
+ const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageOrders, hasPagination, } = pageSlice(allOrders, offset, ORDER_PAGE_SIZE);
3587
3660
  for (let i = 0; i < pageOrders.length; i++) {
3588
3661
  const order = pageOrders[i];
3589
3662
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
@@ -3747,7 +3820,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3747
3820
  label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
3748
3821
  });
3749
3822
  elements.push({ tag: 'hr' });
3750
- elements.push(bar);
3823
+ elements.push(...bar);
3751
3824
  }
3752
3825
  }
3753
3826
  return {