lark-remote 0.1.6 → 0.1.8

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 (216) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.en.md +9 -1
  3. package/README.md +14 -2
  4. package/dist/bridge/approval-coordinator.d.ts +71 -34
  5. package/dist/bridge/approval-coordinator.js +225 -41
  6. package/dist/bridge/inbound-media.d.ts +62 -0
  7. package/dist/bridge/inbound-media.js +282 -0
  8. package/dist/bridge/index.d.ts +97 -13
  9. package/dist/bridge/index.js +227 -87
  10. package/dist/bridge/queue-manager.d.ts +2 -1
  11. package/dist/bridge/queue-manager.js +1 -0
  12. package/dist/card/approval-render.js +294 -36
  13. package/dist/card/bash-card-session.d.ts +2 -24
  14. package/dist/card/bash-card-session.js +6 -73
  15. package/dist/card/bash-renderer.js +2 -3
  16. package/dist/card/card-budget.js +29 -75
  17. package/dist/card/card-session.d.ts +48 -0
  18. package/dist/card/card-session.js +86 -0
  19. package/dist/card/card-shared.d.ts +7 -16
  20. package/dist/card/card-shared.js +7 -30
  21. package/dist/card/run-card-session.d.ts +2 -55
  22. package/dist/card/run-card-session.js +6 -104
  23. package/dist/card/run-renderer.d.ts +7 -0
  24. package/dist/card/run-renderer.js +116 -238
  25. package/dist/card/run-state.d.ts +13 -0
  26. package/dist/card/run-state.js +25 -16
  27. package/dist/card/text-truncate.d.ts +13 -12
  28. package/dist/card/text-truncate.js +13 -63
  29. package/dist/common/fs.d.ts +8 -0
  30. package/dist/common/fs.js +17 -0
  31. package/dist/common/guards.d.ts +0 -20
  32. package/dist/common/guards.js +0 -43
  33. package/dist/common/sleep.d.ts +2 -0
  34. package/dist/common/sleep.js +5 -0
  35. package/dist/common/truncate.d.ts +39 -0
  36. package/dist/common/truncate.js +92 -0
  37. package/dist/config/codex-config.d.ts +1 -1
  38. package/dist/config/codex-config.js +1 -1
  39. package/dist/config/index.d.ts +112 -24
  40. package/dist/config/index.js +139 -37
  41. package/dist/connector/index.d.ts +69 -2
  42. package/dist/connector/index.js +107 -1
  43. package/dist/index.js +150 -118
  44. package/dist/instance-lock.js +4 -12
  45. package/dist/order/alias-resolve.d.ts +13 -0
  46. package/dist/order/alias-resolve.js +22 -0
  47. package/dist/order/alias-store.d.ts +38 -0
  48. package/dist/order/alias-store.js +106 -0
  49. package/dist/persistence/atomic-write.d.ts +8 -2
  50. package/dist/persistence/atomic-write.js +23 -7
  51. package/dist/restart.js +2 -1
  52. package/dist/router/card-action-payload.d.ts +30 -0
  53. package/dist/router/card-action-payload.js +39 -0
  54. package/dist/router/card-helpers.d.ts +63 -0
  55. package/dist/router/card-helpers.js +146 -0
  56. package/dist/router/config/claude.js +30 -1
  57. package/dist/router/config/codex.js +29 -50
  58. package/dist/router/config/common/model-patch.d.ts +22 -0
  59. package/dist/router/config/common/model-patch.js +26 -0
  60. package/dist/router/config/common/render.js +5 -4
  61. package/dist/router/config/dsh.d.ts +51 -0
  62. package/dist/router/config/dsh.js +179 -0
  63. package/dist/router/config/index.d.ts +12 -0
  64. package/dist/router/config/index.js +28 -1
  65. package/dist/router/config/kimi.d.ts +4 -1
  66. package/dist/router/config/kimi.js +27 -0
  67. package/dist/router/config/opencode.js +18 -5
  68. package/dist/router/config/pi.js +4 -5
  69. package/dist/router/config/types.d.ts +17 -2
  70. package/dist/router/index.d.ts +76 -22
  71. package/dist/router/index.js +479 -548
  72. package/dist/router/utils.d.ts +2 -0
  73. package/dist/router/utils.js +16 -2
  74. package/dist/runner/agent-choices-common.d.ts +16 -0
  75. package/dist/runner/agent-choices-common.js +34 -0
  76. package/dist/runner/claude/runner.d.ts +47 -10
  77. package/dist/runner/claude/runner.js +117 -46
  78. package/dist/runner/claude/session.d.ts +166 -0
  79. package/dist/runner/claude/session.js +768 -0
  80. package/dist/runner/codex/app-server/protocol-types.d.ts +66 -39
  81. package/dist/runner/codex/app-server/protocol-types.js +2 -13
  82. package/dist/runner/codex/app-server/runner.d.ts +52 -59
  83. package/dist/runner/codex/app-server/runner.js +216 -338
  84. package/dist/runner/codex/app-server/translator.d.ts +8 -21
  85. package/dist/runner/codex/app-server/translator.js +45 -18
  86. package/dist/runner/codex/index.d.ts +0 -2
  87. package/dist/runner/codex/index.js +0 -2
  88. package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
  89. package/dist/runner/common/acp/protocol-helpers.js +21 -0
  90. package/dist/runner/common/acp/protocol-types.d.ts +271 -0
  91. package/dist/runner/common/acp/protocol-types.js +77 -0
  92. package/dist/runner/common/connection-based-runner.d.ts +111 -0
  93. package/dist/runner/common/connection-based-runner.js +261 -0
  94. package/dist/runner/{codex/app-server → common/jsonrpc}/client.d.ts +14 -7
  95. package/dist/runner/{codex/app-server → common/jsonrpc}/client.js +27 -18
  96. package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
  97. package/dist/runner/{codex/app-server → common/jsonrpc}/connection-manager.js +63 -28
  98. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.d.ts +5 -4
  99. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.js +18 -19
  100. package/dist/runner/common/jsonrpc/types.d.ts +32 -0
  101. package/dist/runner/common/jsonrpc/types.js +9 -0
  102. package/dist/runner/common/process-stopper.d.ts +2 -1
  103. package/dist/runner/common/process-stopper.js +1 -0
  104. package/dist/runner/common/runner-utils.d.ts +6 -13
  105. package/dist/runner/common/runner-utils.js +6 -47
  106. package/dist/runner/common/spawning-runner.d.ts +24 -70
  107. package/dist/runner/common/spawning-runner.js +98 -152
  108. package/dist/runner/dsh/client.d.ts +95 -0
  109. package/dist/runner/dsh/client.js +418 -0
  110. package/dist/runner/dsh/fake-dsh-server.d.ts +60 -0
  111. package/dist/runner/dsh/fake-dsh-server.js +156 -0
  112. package/dist/runner/dsh/index.d.ts +3 -0
  113. package/dist/runner/dsh/index.js +4 -0
  114. package/dist/runner/dsh/runner.d.ts +57 -0
  115. package/dist/runner/dsh/runner.js +191 -0
  116. package/dist/runner/dsh/translator.d.ts +28 -0
  117. package/dist/runner/dsh/translator.js +98 -0
  118. package/dist/runner/dsh/types.d.ts +155 -0
  119. package/dist/runner/dsh/types.js +14 -0
  120. package/dist/runner/index.d.ts +5 -4
  121. package/dist/runner/index.js +5 -4
  122. package/dist/runner/kimi/acp/runner.d.ts +106 -0
  123. package/dist/runner/kimi/acp/runner.js +516 -0
  124. package/dist/runner/kimi/acp/translator.d.ts +138 -0
  125. package/dist/runner/kimi/acp/translator.js +441 -0
  126. package/dist/runner/kimi/index.d.ts +1 -1
  127. package/dist/runner/kimi/index.js +1 -1
  128. package/dist/runner/opencode/acp/index.d.ts +1 -0
  129. package/dist/runner/opencode/acp/index.js +2 -0
  130. package/dist/runner/opencode/acp/runner.d.ts +121 -0
  131. package/dist/runner/opencode/acp/runner.js +429 -0
  132. package/dist/runner/opencode/acp/translator.d.ts +119 -0
  133. package/dist/runner/opencode/acp/translator.js +345 -0
  134. package/dist/runner/opencode/index.d.ts +1 -1
  135. package/dist/runner/opencode/index.js +1 -1
  136. package/dist/runner/pi/index.d.ts +1 -1
  137. package/dist/runner/pi/index.js +1 -1
  138. package/dist/runner/pi/rpc/client.d.ts +52 -0
  139. package/dist/runner/pi/rpc/client.js +114 -0
  140. package/dist/runner/pi/rpc/index.d.ts +4 -0
  141. package/dist/runner/pi/rpc/index.js +4 -0
  142. package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
  143. package/dist/runner/pi/rpc/protocol-types.js +21 -0
  144. package/dist/runner/pi/rpc/runner.d.ts +74 -0
  145. package/dist/runner/pi/rpc/runner.js +190 -0
  146. package/dist/runner/pi/rpc/translator.d.ts +55 -0
  147. package/dist/runner/pi/rpc/translator.js +258 -0
  148. package/dist/runner/probe.js +6 -1
  149. package/dist/runner/question-common.d.ts +26 -0
  150. package/dist/runner/question-common.js +42 -0
  151. package/dist/runner/registry.d.ts +7 -8
  152. package/dist/runner/registry.js +3 -4
  153. package/dist/runner/resolve-agent-choices.js +18 -59
  154. package/dist/runner/sync-agent-choices.js +16 -67
  155. package/dist/runner/types.d.ts +97 -14
  156. package/dist/session/claude/session-index.js +2 -3
  157. package/dist/session/claude/session-reader.js +3 -6
  158. package/dist/session/claude/sessions.js +43 -28
  159. package/dist/session/codex/rollout-reader.d.ts +2 -2
  160. package/dist/session/codex/rollout-reader.js +14 -17
  161. package/dist/session/codex/sessions.d.ts +1 -1
  162. package/dist/session/codex/sessions.js +2 -2
  163. package/dist/session/common/content-blocks.d.ts +7 -0
  164. package/dist/session/common/content-blocks.js +13 -9
  165. package/dist/session/common/jsonl.d.ts +8 -25
  166. package/dist/session/common/jsonl.js +126 -261
  167. package/dist/session/common/pagination.d.ts +28 -0
  168. package/dist/session/common/pagination.js +33 -0
  169. package/dist/session/dsh/index.d.ts +1 -0
  170. package/dist/session/dsh/index.js +2 -0
  171. package/dist/session/dsh/sessions.d.ts +39 -0
  172. package/dist/session/dsh/sessions.js +237 -0
  173. package/dist/session/index.d.ts +1 -0
  174. package/dist/session/index.js +1 -0
  175. package/dist/session/kimi/sessions.d.ts +34 -1
  176. package/dist/session/kimi/sessions.js +208 -98
  177. package/dist/session/opencode/sessions.d.ts +17 -3
  178. package/dist/session/opencode/sessions.js +29 -29
  179. package/dist/session/pi/sessions.d.ts +5 -1
  180. package/dist/session/pi/sessions.js +12 -16
  181. package/dist/session/session-store.d.ts +11 -1
  182. package/dist/session/session-store.js +75 -178
  183. package/dist/update/index.d.ts +2 -2
  184. package/dist/update/index.js +2 -2
  185. package/dist/update/install.d.ts +20 -2
  186. package/dist/update/install.js +51 -7
  187. package/dist/workspace/index.d.ts +4 -0
  188. package/dist/workspace/index.js +8 -1
  189. package/package.json +2 -1
  190. package/dist/runner/codex/app-server/connection-manager.d.ts +0 -56
  191. package/dist/runner/codex/argv.d.ts +0 -33
  192. package/dist/runner/codex/argv.js +0 -40
  193. package/dist/runner/codex/factory.d.ts +0 -29
  194. package/dist/runner/codex/factory.js +0 -39
  195. package/dist/runner/codex/jsonl.d.ts +0 -37
  196. package/dist/runner/codex/jsonl.js +0 -221
  197. package/dist/runner/codex/runner.d.ts +0 -66
  198. package/dist/runner/codex/runner.js +0 -129
  199. package/dist/runner/common/exec-translator.d.ts +0 -73
  200. package/dist/runner/common/exec-translator.js +0 -105
  201. package/dist/runner/kimi/jsonl.d.ts +0 -28
  202. package/dist/runner/kimi/jsonl.js +0 -143
  203. package/dist/runner/kimi/runner.d.ts +0 -49
  204. package/dist/runner/kimi/runner.js +0 -105
  205. package/dist/runner/opencode/argv.d.ts +0 -32
  206. package/dist/runner/opencode/argv.js +0 -35
  207. package/dist/runner/opencode/jsonl.d.ts +0 -68
  208. package/dist/runner/opencode/jsonl.js +0 -295
  209. package/dist/runner/opencode/runner.d.ts +0 -61
  210. package/dist/runner/opencode/runner.js +0 -93
  211. package/dist/runner/pi/jsonl.d.ts +0 -128
  212. package/dist/runner/pi/jsonl.js +0 -249
  213. package/dist/runner/pi/runner.d.ts +0 -36
  214. package/dist/runner/pi/runner.js +0 -76
  215. package/dist/session/common/catch-up.d.ts +0 -12
  216. package/dist/session/common/catch-up.js +0 -31
@@ -2,18 +2,20 @@ 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, getAgentConfig, mapAgentKey, assertSafeKeyPart, } from '../config/index.js';
5
+ import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, walkNestedContainer, 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
+ import { AliasStore } from '../order/alias-store.js';
10
+ import { resolveAlias } from '../order/alias-resolve.js';
9
11
  import { getLogger } from '../logger/index.js';
10
- import { activeRunUsage, formatUsageStats } from './utils.js';
11
- import { markdownDiv, sessionEventPanel } from './card-helpers.js';
12
+ import { activeRunUsage, clampInt } from './utils.js';
13
+ import { markdownDiv, buildSessionHistoryCard, paginationBar } from './card-helpers.js';
12
14
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
13
15
  import { atomicWrite } from '../persistence/atomic-write.js';
14
16
  import { checkLatestVersion as defaultCheckLatestVersion, isNewer as defaultIsNewer, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
15
17
  /** Config card builder - delegates to per-agent builders */
16
- import { getConfigBuilder, listRegisteredAgents } from './config/index.js';
18
+ import { getConfigBuilder, listRegisteredAgents, sortAgentsForDisplay } from './config/index.js';
17
19
  import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
18
20
  import { buildConfigCardFromTabs } from './config/common/render.js';
19
21
  /** Max events to read for auto-resume and /resume cards.
@@ -37,6 +39,21 @@ const ORDER_PAGE_SIZE = 15;
37
39
  * 留足余量。
38
40
  */
39
41
  const WS_PAGE_SIZE = 5;
42
+ /**
43
+ * 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
44
+ * 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
45
+ *
46
+ * resume.page / active.page 共用,避免两处重复实现分页归一化。
47
+ */
48
+ function normalizePageArgs(value, maxPageSize) {
49
+ const rawPageSize = Number(value.pageSize);
50
+ const pageSize = Number.isFinite(rawPageSize)
51
+ ? clampInt(Math.trunc(rawPageSize), 1, maxPageSize)
52
+ : maxPageSize;
53
+ const rawOffset = Number(value.offset);
54
+ const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
55
+ return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
56
+ }
40
57
  /** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
41
58
  const RESUME_CONTENT_PREFETCH = 5;
42
59
  /**
@@ -101,17 +118,25 @@ export function isImmediateAction(cmd) {
101
118
  // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
102
119
  // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
103
120
  cmd === 'approval.respond' ||
104
- cmd === 'approval.toggle');
121
+ cmd === 'approval.toggle' ||
122
+ cmd === 'approval.answer' ||
123
+ cmd === 'approval.answerSubmit' ||
124
+ cmd === 'approval.answerCustom' ||
125
+ cmd === 'approval.answerNote');
105
126
  }
106
127
  export class CommandRouter {
107
128
  /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
108
- static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
129
+ static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
109
130
  sessionStore;
131
+ /** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
110
132
  bridge;
133
+ /** 当前配置(public:测试直接读取断言,替代 as unknown as)。 */
111
134
  config;
135
+ /** 配置文件的绝对路径(public:/config 保存链路与测试共用)。 */
112
136
  configPath;
113
137
  workspaceStore;
114
138
  orderStore;
139
+ aliasStore;
115
140
  exitHandler;
116
141
  pendingExit = false;
117
142
  /**
@@ -126,7 +151,8 @@ export class CommandRouter {
126
151
  updateCachePath;
127
152
  /** Injected update functions (for testability; defaults to real implementation). */
128
153
  updateFns;
129
- pendingConfig = null; // /config 卡片编辑暂存区
154
+ /** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
155
+ pendingConfig = null;
130
156
  /**
131
157
  * Monotonic counter minting unique internal keys for order.exec enqueue
132
158
  * actions. One order card can be clicked many times, so the Feishu card
@@ -147,6 +173,7 @@ export class CommandRouter {
147
173
  * it uses `listRegisteredAgents()` from `router/config/index.ts` (the
148
174
  * config-builder registry), which mirrors the agents with a config card.
149
175
  */
176
+ /** Session reader 注册中心(public:测试注入/读取,替代 as unknown as)。 */
150
177
  sessionReaderRegistry;
151
178
  /**
152
179
  * 串行化 config.* 卡片回调(2026-07-04)。
@@ -164,6 +191,7 @@ export class CommandRouter {
164
191
  this.configPath = opts.configPath;
165
192
  this.workspaceStore = new WorkspaceStore(opts.workspacePath);
166
193
  this.orderStore = new OrderStore(opts.ordersPath);
194
+ this.aliasStore = new AliasStore(opts.aliasesPath);
167
195
  this.exitHandler = opts.exitHandler ?? (() => process.exit(0));
168
196
  this.restartSpawner = opts.restartSpawner;
169
197
  // idle watchdog 窗口从 config.idle.watchdogMinutes 读取
@@ -202,6 +230,8 @@ export class CommandRouter {
202
230
  await this.bridge.sendResult(result, ctx);
203
231
  }
204
232
  if (this.pendingExit) {
233
+ // 干净退出前冲刷待合批的媒体保存提示,避免 500ms 窗口内的提示丢失。
234
+ await this.bridge.flushAllMediaNotifications();
205
235
  this.exitHandler();
206
236
  }
207
237
  return result;
@@ -222,6 +252,13 @@ export class CommandRouter {
222
252
  await this.bridge.forwardToClaude(trimmed, ctx, opts);
223
253
  return null;
224
254
  }
255
+ /**
256
+ * 一次别名展开:bridge 收到用户消息后、命令分发前调用。
257
+ * 仅 `$name` 开头的消息可能被展开;未知别名 / 非 `$` 消息原样返回。
258
+ */
259
+ expandAliasMessage(message) {
260
+ return resolveAlias(message, this.aliasStore.list()) ?? message;
261
+ }
225
262
  /**
226
263
  * Handle a CardKit card button click (§6.2).
227
264
  * Validates payload and dispatches to the corresponding command.
@@ -326,6 +363,10 @@ export class CommandRouter {
326
363
  return this.enqueueConfigAction(value, ctx);
327
364
  case 'approval.respond':
328
365
  case 'approval.toggle':
366
+ case 'approval.answer':
367
+ case 'approval.answerSubmit':
368
+ case 'approval.answerCustom':
369
+ case 'approval.answerNote':
329
370
  return this.handleApprovalAction(value, ctx);
330
371
  case 'codex.compact':
331
372
  await this.bridge.handleCodexCompact(value, ctx);
@@ -346,6 +387,7 @@ export class CommandRouter {
346
387
  // 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
347
388
  // 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
348
389
  if (this.pendingExit) {
390
+ await this.bridge.flushAllMediaNotifications();
349
391
  this.exitHandler();
350
392
  }
351
393
  return;
@@ -363,8 +405,16 @@ export class CommandRouter {
363
405
  * Routes to the appropriate bridge method based on value.cmd.
364
406
  * Returns a toast response for immediate user feedback.
365
407
  */
408
+ /** 答案类操作的统一失败反馈:重复投递(同一 nonce 第二次点击)是已生效的
409
+ * 中性事件,报 error 会误导(首次点击实际已成功)。 */
410
+ answerFailureToast(msg) {
411
+ if (/already submitted \(duplicate nonce\)/.test(msg)) {
412
+ return { toast: { type: 'info', content: '该选项已处理,请勿重复点击' } };
413
+ }
414
+ return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
415
+ }
366
416
  async handleApprovalAction(value, _ctx) {
367
- const { runId, requestId, decision, scope, nonce, permId } = value;
417
+ const { runId, requestId, decision, nonce, permId } = value;
368
418
  if (value.cmd === 'approval.respond') {
369
419
  if (!runId || requestId === undefined || !decision || !nonce) {
370
420
  return { toast: { type: 'error', content: '缺少审批响应参数' } };
@@ -374,7 +424,6 @@ export class CommandRouter {
374
424
  runId,
375
425
  requestId,
376
426
  decision,
377
- scope,
378
427
  nonce,
379
428
  });
380
429
  return { toast: { type: 'success', content: '审批已提交' } };
@@ -389,7 +438,7 @@ export class CommandRouter {
389
438
  }
390
439
  }
391
440
  if (value.cmd === 'approval.toggle') {
392
- if (!runId || requestId === undefined || !permId || !nonce) {
441
+ if (!runId || requestId === undefined || !permId) {
393
442
  return { toast: { type: 'error', content: '缺少权限切换参数' } };
394
443
  }
395
444
  try {
@@ -397,7 +446,6 @@ export class CommandRouter {
397
446
  runId,
398
447
  requestId,
399
448
  permId,
400
- nonce,
401
449
  selected: value.selected ?? true,
402
450
  });
403
451
  return { toast: { type: 'info', content: '已切换' } };
@@ -407,6 +455,93 @@ export class CommandRouter {
407
455
  return { toast: { type: 'error', content: `权限切换失败:${msg}` } };
408
456
  }
409
457
  }
458
+ if (value.cmd === 'approval.answer') {
459
+ if (!runId ||
460
+ requestId === undefined ||
461
+ value.questionIndex === undefined ||
462
+ !value.option ||
463
+ !nonce) {
464
+ return { toast: { type: 'error', content: '缺少问题答案参数' } };
465
+ }
466
+ try {
467
+ await this.bridge.handleApprovalAnswer({
468
+ runId,
469
+ requestId,
470
+ questionIndex: value.questionIndex,
471
+ option: value.option,
472
+ nonce,
473
+ });
474
+ return { toast: { type: 'success', content: '已选择' } };
475
+ }
476
+ catch (err) {
477
+ const msg = err instanceof Error ? err.message : String(err);
478
+ return this.answerFailureToast(msg);
479
+ }
480
+ }
481
+ if (value.cmd === 'approval.answerSubmit') {
482
+ if (!runId || requestId === undefined || value.questionIndex === undefined || !nonce) {
483
+ return { toast: { type: 'error', content: '缺少提交参数' } };
484
+ }
485
+ try {
486
+ await this.bridge.handleApprovalAnswerSubmit({
487
+ runId,
488
+ requestId,
489
+ questionIndex: value.questionIndex,
490
+ nonce,
491
+ });
492
+ return { toast: { type: 'success', content: '答案已提交' } };
493
+ }
494
+ catch (err) {
495
+ const msg = err instanceof Error ? err.message : String(err);
496
+ return this.answerFailureToast(msg);
497
+ }
498
+ }
499
+ if (value.cmd === 'approval.answerCustom') {
500
+ if (!runId ||
501
+ requestId === undefined ||
502
+ value.questionIndex === undefined ||
503
+ !nonce ||
504
+ !value.inputValue) {
505
+ return { toast: { type: 'error', content: '缺少答案文本参数' } };
506
+ }
507
+ try {
508
+ await this.bridge.handleApprovalAnswerCustom({
509
+ runId,
510
+ requestId,
511
+ questionIndex: value.questionIndex,
512
+ text: value.inputValue,
513
+ nonce,
514
+ });
515
+ return { toast: { type: 'success', content: '答案已提交' } };
516
+ }
517
+ catch (err) {
518
+ const msg = err instanceof Error ? err.message : String(err);
519
+ return this.answerFailureToast(msg);
520
+ }
521
+ }
522
+ if (value.cmd === 'approval.answerNote') {
523
+ if (!runId ||
524
+ requestId === undefined ||
525
+ value.questionIndex === undefined ||
526
+ !nonce ||
527
+ !value.inputValue) {
528
+ return { toast: { type: 'error', content: '缺少补充说明参数' } };
529
+ }
530
+ try {
531
+ await this.bridge.handleApprovalAnswerNote({
532
+ runId,
533
+ requestId,
534
+ questionIndex: value.questionIndex,
535
+ text: value.inputValue,
536
+ nonce,
537
+ });
538
+ return { toast: { type: 'success', content: '补充说明已保存' } };
539
+ }
540
+ catch (err) {
541
+ const msg = err instanceof Error ? err.message : String(err);
542
+ return this.answerFailureToast(msg);
543
+ }
544
+ }
410
545
  return { toast: { type: 'error', content: '未知的审批操作' } };
411
546
  }
412
547
  /**
@@ -888,17 +1023,7 @@ export class CommandRouter {
888
1023
  const resolvedAgent = validAgents.includes(resumeAgent)
889
1024
  ? resumeAgent
890
1025
  : this.config.defaultAgent;
891
- const rawPageSize = Number(value.pageSize);
892
- const pageSize = Number.isFinite(rawPageSize)
893
- ? Math.min(Math.max(Math.trunc(rawPageSize), 1), RESUME_PAGE_SIZE)
894
- : RESUME_PAGE_SIZE;
895
- // Normalize the callback offset to a finite non-negative integer, then
896
- // align it to a page boundary so prev/next always step by pageSize.
897
- // Non-numeric payloads (e.g. 'abc') must not produce NaN slices that look
898
- // like an empty directory.
899
- const rawOffset = Number(value.offset);
900
- const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
901
- const alignedOffset = Math.floor(offset / pageSize) * pageSize;
1026
+ const { pageSize, alignedOffset } = normalizePageArgs(value, RESUME_PAGE_SIZE);
902
1027
  const entry = this.sessionStore.get(ctx.userId);
903
1028
  const cwd = entry?.cwd;
904
1029
  if (!cwd) {
@@ -925,9 +1050,7 @@ export class CommandRouter {
925
1050
  * Mirrors handleResumePage — updates the same card, never sends a new one.
926
1051
  */
927
1052
  async handleActivePage(value, ctx) {
928
- const rawOffset = Number(value.offset);
929
- const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
930
- const alignedOffset = Math.floor(offset / ACTIVE_PAGE_SIZE) * ACTIVE_PAGE_SIZE;
1053
+ const { alignedOffset } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
931
1054
  const result = this.cmdActive([], ctx, alignedOffset);
932
1055
  if (!result.card) {
933
1056
  return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
@@ -1186,6 +1309,7 @@ export class CommandRouter {
1186
1309
  return next;
1187
1310
  }
1188
1311
  /** 实际的 config.* 分发逻辑(在串行队列内执行) */
1312
+ /** config.* 卡片动作分发(public:测试直接调用,替代 as unknown as)。 */
1189
1313
  async dispatchConfigAction(value, ctx) {
1190
1314
  switch (value.cmd) {
1191
1315
  case 'config.toggle': {
@@ -1286,6 +1410,8 @@ export class CommandRouter {
1286
1410
  // 2026-08-03: 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
1287
1411
  // 后续切换消息照常发送;真正写盘/传播阶段的失败才走外层 catch 报「保存失败」。
1288
1412
  try {
1413
+ // host 可能已变更 → 重新预取 DSH 目录(内部按 host 缓存,未变不重复拉)
1414
+ await this.prefetchConfigCardCatalogs();
1289
1415
  await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
1290
1416
  }
1291
1417
  catch (refreshErr) {
@@ -1314,37 +1440,29 @@ export class CommandRouter {
1314
1440
  }
1315
1441
  }
1316
1442
  /** 设置嵌套属性值(仅修改内存对象,不写盘) */
1443
+ /** 在 pendingConfig 上按 dot-separated key 设置嵌套值(config 卡片编辑用)。 */
1317
1444
  setNestedValue(target, key, value) {
1318
1445
  // Path mapping 共用 config 模块的 mapAgentKey(G11 Inconsistency 修复):
1319
1446
  // pi.xxx/codex.xxx/opencode.xxx → agents.xxx,claude.xxx 保持顶层
1320
1447
  const mappedKey = mapAgentKey(key);
1321
1448
  const parts = mappedKey.split('.');
1322
- let current = target;
1323
- for (let i = 0; i < parts.length - 1; i++) {
1324
- // §P1-4:卡片 config.set/config.input 的 key 来自卡片 action,
1325
- // 必须与 config 模块的 setNestedValue/deleteNestedValue/getConfigValue 同等
1326
- // 守卫 __proto__/prototype/constructor 段——否则伪造 key 经
1327
- // `current['__proto__']` 污染 Object.prototype(文本直写路径已守卫,
1328
- // 卡片路径是同一威胁模型的另一入口)。
1329
- assertSafeKeyPart(parts[i]);
1330
- const next = current[parts[i]];
1331
- if (next == null || typeof next !== 'object') {
1332
- current[parts[i]] = {};
1333
- }
1334
- current = current[parts[i]];
1335
- }
1336
- assertSafeKeyPart(parts[parts.length - 1]);
1449
+ // 复用 config 模块的 walkNestedContainer(含 __proto__/prototype/constructor
1450
+ // 守卫与中间段补 {}),router 只保留 value=undefined → delete 分支。
1451
+ const container = walkNestedContainer(target, parts, true);
1452
+ const lastPart = parts[parts.length - 1];
1453
+ assertSafeKeyPart(lastPart);
1337
1454
  if (value === undefined) {
1338
1455
  // value=undefined 表示"删除键"(如清空 reasoningEffort),
1339
1456
  // 不能写成 undefined 值——diffConfig 会把 undefined 转成字面量 "undefined"
1340
1457
  // 写入 config.yaml 并透传给 codex(ReasoningEffort::Custom("undefined"))。
1341
- delete current[parts[parts.length - 1]];
1458
+ delete container[lastPart];
1342
1459
  }
1343
1460
  else {
1344
- current[parts[parts.length - 1]] = value;
1461
+ container[lastPart] = value;
1345
1462
  }
1346
1463
  }
1347
1464
  /** 对比原始 config 与 pendingConfig,返回变化的 key→string 映射 */
1465
+ /** config diff(public:测试直接调用,替代 as unknown as)。 */
1348
1466
  diffConfig(original, pending) {
1349
1467
  const updates = {};
1350
1468
  this.collectDiff('', original, pending, updates);
@@ -1470,6 +1588,24 @@ export class CommandRouter {
1470
1588
  switchSessionId = '';
1471
1589
  }
1472
1590
  }
1591
+ // DSH preset 变更 = 下次 run 新建 session(§4.3 D1):
1592
+ // preset 在 session 创建时固定,中途切换会被服务端拒绝(agent-preset-conflict)。
1593
+ // 旧 sessionId 存入 previousSessions 停车位(可手动 /resume,但 /resume 会做
1594
+ // preset 一致性校验),当前 sessionId 清空——下次 run 走 session.create 新建。
1595
+ const hasDshPresetChange = Object.keys(updates).some((k) => k === 'agents.dsh.agentPreset');
1596
+ // 已发生 agent 切换时(switchNotice 已设置)preset 分支跳过,避免覆盖切换 notice;
1597
+ // 切换分支对 dsh 的 session 已做恢复/清空处理。
1598
+ if (hasDshPresetChange && this.config.defaultAgent === 'dsh' && !switchNotice) {
1599
+ const oldSessionId = this.sessionStore.getSessionId(ctx.userId, 'dsh');
1600
+ if (oldSessionId) {
1601
+ this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
1602
+ this.sessionStore.clearSessionId(ctx.userId, 'dsh');
1603
+ switchNotice = `DSH preset 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
1604
+ }
1605
+ else {
1606
+ switchNotice = `DSH preset 已变更,下次消息将新建会话`;
1607
+ }
1608
+ }
1473
1609
  // 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
1474
1610
  const currentAgent = this.config.defaultAgent;
1475
1611
  const currentAgentConfigKey = `agents.${currentAgent}`;
@@ -1480,6 +1616,27 @@ export class CommandRouter {
1480
1616
  atomicWrite(this.configPath, YAML.stringify(this.config));
1481
1617
  this.bridge.setConfig(this.config);
1482
1618
  }
1619
+ // P1-6 + live approval settings (§P5): active workspace-lifetime runners
1620
+ // are not evicted by clearRunners() (would orphan the live subprocess).
1621
+ // Push the new approval-mode settings to those runners via the unified
1622
+ // duck method `updateApprovalMode` (codex thread/settings/update,
1623
+ // kimi/opencode session/set_mode) so they take effect for subsequent
1624
+ // turns without waiting for the runner to be recreated.
1625
+ const hasApprovalConfigChange = Object.keys(updates).some((k) => k === 'agents.codex' ||
1626
+ k.startsWith('agents.codex.') ||
1627
+ k === 'codex' ||
1628
+ k.startsWith('codex.') ||
1629
+ k === 'agents.kimi' ||
1630
+ k.startsWith('agents.kimi.') ||
1631
+ k === 'kimi' ||
1632
+ k.startsWith('kimi.') ||
1633
+ k === 'agents.opencode' ||
1634
+ k.startsWith('agents.opencode.') ||
1635
+ k === 'opencode' ||
1636
+ k.startsWith('opencode.'));
1637
+ if (hasApprovalConfigChange) {
1638
+ this.bridge.syncActiveApprovalModes();
1639
+ }
1483
1640
  // 2026-07-13: 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
1484
1641
  if (hasDefaultAgentChange || hasAgentConfigChange) {
1485
1642
  this.bridge.clearRunners();
@@ -1560,7 +1717,11 @@ export class CommandRouter {
1560
1717
  { cmd: 'cd', label: '/cd <path>', desc: '切换工作目录' },
1561
1718
  { cmd: 'ls', label: '/ls [dir]', desc: '列出当前目录,可指定子目录' },
1562
1719
  { cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
1563
- { cmd: 'order', label: '/order /o save|list', desc: '保存或列出常用指令(/order 默认列出)' },
1720
+ {
1721
+ cmd: 'order',
1722
+ label: '/order /o save|list',
1723
+ desc: '收藏指令 /order save,快捷别名 /order alias',
1724
+ },
1564
1725
  ];
1565
1726
  const bodyElements = [];
1566
1727
  // 按钮组 — CardKit 2.0: column_set + column + button with behaviors
@@ -1641,6 +1802,7 @@ export class CommandRouter {
1641
1802
  },
1642
1803
  };
1643
1804
  }
1805
+ /** /status 实现(public:测试直接调用,替代 as any)。 */
1644
1806
  cmdStatus(ctx) {
1645
1807
  const entry = this.sessionStore.get(ctx.userId);
1646
1808
  const cwd = entry?.cwd ?? '(未设置)';
@@ -1886,84 +2048,30 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1886
2048
  buildAutoResumeCard(cwd, session, _ctx) {
1887
2049
  const agentName = agentDisplayName(this.config.defaultAgent);
1888
2050
  const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(session.sessionId, cwd, { maxEvents: AUTO_RESUME_MAX_EVENTS });
1889
- // Build header with displayTitle (aiTitle or last user message) and recap
1890
- let header = `📂 \`${cwd}\`\n已恢复最近会话: **${session.sessionId}**`;
1891
- const sections = [];
1892
- if (displayTitle) {
1893
- const label = aiTitle ? 'AI 标题' : '最近输入';
1894
- sections.push(`🏷️ **${label}**\n${displayTitle}`);
1895
- }
1896
- if (recap) {
1897
- const recapPreview = recap.length > 200 ? recap.slice(0, 197) + '...' : recap;
1898
- sections.push(`📝 **Recap**\n${recapPreview}`);
1899
- }
1900
- if (sections.length > 0) {
1901
- header += '\n\n' + sections.join('\n\n──\n\n');
1902
- }
1903
- const elements = [markdownDiv(header), { tag: 'hr' }];
1904
- // Fold session history into collapsible panels: the last 2 events stay
1905
- // expanded so the user sees the most recent context without clicking;
1906
- // older events are collapsed to keep the card compact.
1907
- content.forEach((ev, i) => {
1908
- elements.push(sessionEventPanel(ev, i, content.length, 2, this.config.defaultAgent));
2051
+ const actions = this.buildResumeActionButtons({
2052
+ isActive,
2053
+ activeRunRunId,
2054
+ cwd,
2055
+ compact: this.canCompactSession(this.config.defaultAgent, cwd)
2056
+ ? { sessionId: session.sessionId, agentKind: this.config.defaultAgent }
2057
+ : undefined,
2058
+ newSession: true,
1909
2059
  });
1910
- // Add usage/stats at the end
1911
- if (usage) {
1912
- const usageStr = formatUsageStats(usage, { showResult: true, result: 'success' });
1913
- elements.push({
1914
- tag: 'div',
1915
- text: { tag: 'lark_md', content: usageStr },
1916
- });
1917
- }
1918
- // Remove trailing hr and add action buttons
1919
- if (elements.length > 0 && elements[elements.length - 1].tag === 'hr') {
1920
- elements.pop();
1921
- }
1922
- const actions = [];
1923
- // Stop button for active sessions (isActive = in-memory activeRun is
1924
- // non-terminal, which guarantees activeRunRunId is set). Bind the REAL
1925
- // activeRun.runId (UUID) so bridge.interruptCurrentRun can match and
1926
- // stop a live run.
1927
- if (isActive) {
1928
- actions.push({
1929
- tag: 'button',
1930
- text: { tag: 'plain_text', content: '⏹ 终止' },
1931
- type: 'danger',
1932
- behaviors: [
1933
- {
1934
- type: 'callback',
1935
- value: { cmd: 'stop', runId: activeRunRunId, cwd },
1936
- },
1937
- ],
1938
- });
1939
- }
1940
- // Compact 按钮:仅 codex app-server 模式(runner 有 runCompact)且会话未在跑。
1941
- // 在途 run 会占住 app-server runner,压缩必然失败,此时不渲染。
1942
- if (!isActive && this.canCompactSession(this.config.defaultAgent)) {
1943
- actions.push(resumeCompactButton(session.sessionId, this.config.defaultAgent));
1944
- }
1945
- // New session button always shown (use shared function for consistency)
1946
- actions.push(newSessionButton());
1947
- // Use column_set+column for 2.0 (action tag not supported in 2.0)
1948
- elements.push({
1949
- tag: 'column_set',
1950
- columns: actions.map((btn) => ({
1951
- tag: 'column',
1952
- width: 'auto',
1953
- elements: [btn],
1954
- })),
2060
+ const card = buildSessionHistoryCard({
2061
+ sessionId: session.sessionId,
2062
+ cwd,
2063
+ displayTitle,
2064
+ aiTitle,
2065
+ recap,
2066
+ events: content,
2067
+ usage,
2068
+ }, {
2069
+ agentKind: this.config.defaultAgent,
2070
+ headerText: `📂 \`${cwd}\`\n已恢复最近会话: **${session.sessionId}**`,
2071
+ title: `${isActive ? '⏳ 自动恢复会话(完成中)' : '🔁 自动恢复会话'} · ${agentName}`,
2072
+ usageResult: 'success',
2073
+ actions,
1955
2074
  });
1956
- const card = {
1957
- schema: '2.0',
1958
- config: { wide_screen_mode: true },
1959
- header: {
1960
- title: {
1961
- tag: 'plain_text',
1962
- content: `${isActive ? '⏳ 自动恢复会话(完成中)' : '🔁 自动恢复会话'} · ${agentName}`,
1963
- },
1964
- },
1965
- body: { elements },
1966
- };
1967
2075
  return { card };
1968
2076
  }
1969
2077
  /**
@@ -1977,20 +2085,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1977
2085
  const agentName = agentDisplayName(newAgent);
1978
2086
  const cwd = this.sessionStore.getCwd(ctx.userId);
1979
2087
  const sessionId = switchResult.sessionId;
1980
- // No cwd or no session → compact notice card
2088
+ // No cwd or no session → compact notice card (no new-session button: the
2089
+ // next message already starts a fresh session, a button would mislead
2090
+ // users into thinking a click is required)
1981
2091
  if (!cwd || !sessionId) {
1982
2092
  const elements = [markdownDiv(switchResult.notice)];
1983
- // New session button
1984
- elements.push({
1985
- tag: 'column_set',
1986
- columns: [
1987
- {
1988
- tag: 'column',
1989
- width: 'auto',
1990
- elements: [newSessionButton()],
1991
- },
1992
- ],
1993
- });
1994
2093
  return {
1995
2094
  card: {
1996
2095
  schema: '2.0',
@@ -2034,79 +2133,79 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2034
2133
  agentKind: newAgent,
2035
2134
  maxEvents: AUTO_RESUME_MAX_EVENTS,
2036
2135
  });
2037
- let header = `📂 \`${cwd}\`\n已恢复会话: **${sessionId}**`;
2038
- const sections = [];
2039
- if (displayTitle) {
2040
- const label = aiTitle ? 'AI 标题' : '最近输入';
2041
- sections.push(`🏷️ **${label}**\n${displayTitle}`);
2042
- }
2043
- if (recap) {
2044
- const recapPreview = recap.length > 200 ? recap.slice(0, 197) + '...' : recap;
2045
- sections.push(`📝 **Recap**\n${recapPreview}`);
2046
- }
2047
- if (sections.length > 0) {
2048
- header += '\n\n' + sections.join('\n\n──\n\n');
2049
- }
2050
- const elements = [markdownDiv(header), { tag: 'hr' }];
2051
- if (content.length === 0) {
2052
- elements.push(markdownDiv('_该会话暂无新消息可显示(最后一条为用户输入)_'));
2053
- }
2054
- else {
2055
- content.forEach((ev, i) => {
2056
- elements.push(sessionEventPanel(ev, i, content.length, 2, newAgent));
2057
- });
2058
- }
2059
- if (usage) {
2060
- const usageStr = formatUsageStats(usage, { showResult: true, result: 'success' });
2061
- elements.push(markdownDiv(usageStr));
2062
- }
2063
- if (elements.length > 0 && elements[elements.length - 1].tag === 'hr') {
2064
- elements.pop();
2065
- }
2066
- // Action buttons
2136
+ // Action buttons: stop (active) + new session
2137
+ const actions = this.buildResumeActionButtons({
2138
+ isActive,
2139
+ activeRunRunId,
2140
+ cwd,
2141
+ newSession: true,
2142
+ });
2143
+ const card = buildSessionHistoryCard({
2144
+ sessionId,
2145
+ cwd,
2146
+ displayTitle,
2147
+ aiTitle,
2148
+ recap,
2149
+ events: content,
2150
+ usage,
2151
+ }, {
2152
+ agentKind: newAgent,
2153
+ headerText: `📂 \`${cwd}\`\n已恢复会话: **${sessionId}**`,
2154
+ title: `${isActive ? '⏳ 切换 Agent(完成中)' : '🔀 切换 Agent'} · ${agentName}`,
2155
+ usageResult: 'success',
2156
+ emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
2157
+ actions,
2158
+ });
2159
+ return { card };
2160
+ }
2161
+ /**
2162
+ * Whether a session of the given agent kind can be compacted from a resume
2163
+ * card. Design doc §6.2-2: duck-typing — check if the runner has `runCompact`
2164
+ * (codex app-server, kimi acp), instead of hardcoding agentKind === 'codex'.
2165
+ */
2166
+ canCompactSession(agentKind, cwd) {
2167
+ if (!cwd)
2168
+ return false;
2169
+ return this.bridge.hasRunCompact(cwd, agentKind);
2170
+ }
2171
+ /**
2172
+ * Assemble the action buttons for a resume / session-history card.
2173
+ * Shared by buildAutoResumeCard, buildConfigSwitchCard, and cmdResume so the
2174
+ * button set (stop / compact / new-session) follows one contract instead of
2175
+ * drifting per call site.
2176
+ *
2177
+ * - stop: only when the session has an in-memory active run (isActive), bound
2178
+ * to the real activeRun.runId so bridge.interruptCurrentRun can match.
2179
+ * - compact: only when a compact-capable runner applies and the session is
2180
+ * not running (a live run would occupy the runner, so compaction would
2181
+ * fail). The caller passes `compact` only when canCompactSession holds.
2182
+ * - new-session: when requested (always shown on resume cards).
2183
+ */
2184
+ buildResumeActionButtons(opts) {
2067
2185
  const actions = [];
2068
- if (isActive) {
2186
+ if (opts.isActive) {
2069
2187
  actions.push({
2070
2188
  tag: 'button',
2071
2189
  text: { tag: 'plain_text', content: '⏹ 终止' },
2072
2190
  type: 'danger',
2073
- behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: activeRunRunId, cwd } }],
2191
+ behaviors: [
2192
+ {
2193
+ type: 'callback',
2194
+ value: { cmd: 'stop', runId: opts.activeRunRunId, cwd: opts.cwd },
2195
+ },
2196
+ ],
2074
2197
  });
2075
2198
  }
2076
- actions.push(newSessionButton());
2077
- elements.push({
2078
- tag: 'column_set',
2079
- columns: actions.map((btn) => ({
2080
- tag: 'column',
2081
- width: 'auto',
2082
- elements: [btn],
2083
- })),
2084
- });
2085
- return {
2086
- card: {
2087
- schema: '2.0',
2088
- config: { wide_screen_mode: true },
2089
- header: {
2090
- title: {
2091
- tag: 'plain_text',
2092
- content: `${isActive ? '⏳ 切换 Agent(完成中)' : '🔀 切换 Agent'} · ${agentName}`,
2093
- },
2094
- },
2095
- body: { elements },
2096
- },
2097
- };
2098
- }
2099
- /**
2100
- * Whether a session of the given agent kind can be compacted from a resume
2101
- * card. Compact (thread/compact/start) is only implemented by the codex
2102
- * app-server runner (`runCompact`), so the button is gated on
2103
- * `serviceMode === 'app-server'` — exec mode spawns a fresh CLI per message
2104
- * and has no persistent session to compact.
2105
- */
2106
- canCompactSession(agentKind) {
2107
- return (agentKind === 'codex' && getAgentConfig(this.config, 'codex')?.serviceMode === 'app-server');
2199
+ if (opts.compact && !opts.isActive) {
2200
+ actions.push(resumeCompactButton(opts.compact.sessionId, opts.compact.agentKind));
2201
+ }
2202
+ if (opts.newSession) {
2203
+ actions.push(newSessionButton());
2204
+ }
2205
+ return actions;
2108
2206
  }
2109
2207
  static LS_PAGE_SIZE = 30;
2208
+ /** /ls 实现(public:测试直接调用,替代 as unknown as)。 */
2110
2209
  cmdLs(args, ctx, offset = 0) {
2111
2210
  const cwd = this.sessionStore.getCwd(ctx.userId);
2112
2211
  if (!cwd) {
@@ -2290,78 +2389,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2290
2389
  }
2291
2390
  // Pagination bar (only shown when there are more items than PAGE_SIZE)
2292
2391
  if (hasPagination) {
2293
- const hasPrev = offset > 0;
2294
- const hasNext = offset + CommandRouter.LS_PAGE_SIZE < totalCount;
2295
- // Build columns dynamically — Feishu rejects empty elements[] in a column
2296
- // (ErrCode 200621: "no tag specified"). Only include columns that have content.
2297
- const pageColumns = [];
2298
- if (hasPrev) {
2299
- pageColumns.push({
2300
- tag: 'column',
2301
- width: 'auto',
2302
- vertical_align: 'center',
2303
- elements: [
2304
- {
2305
- tag: 'button',
2306
- text: { tag: 'plain_text', content: '⬅ 上一页' },
2307
- type: 'default',
2308
- size: 'small',
2309
- behaviors: [
2310
- {
2311
- type: 'callback',
2312
- value: {
2313
- cmd: 'ls.page',
2314
- path: targetDir,
2315
- offset: offset - CommandRouter.LS_PAGE_SIZE,
2316
- },
2317
- },
2318
- ],
2319
- },
2320
- ],
2321
- });
2322
- }
2323
- pageColumns.push({
2324
- tag: 'column',
2325
- width: 'weighted',
2326
- weight: 1,
2327
- vertical_align: 'center',
2328
- elements: [
2329
- {
2330
- tag: 'div',
2331
- text: {
2332
- tag: 'lark_md',
2333
- content: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
2334
- },
2335
- },
2336
- ],
2392
+ const bar = paginationBar({
2393
+ cmd: 'ls.page',
2394
+ offset,
2395
+ pageSize: CommandRouter.LS_PAGE_SIZE,
2396
+ total: totalCount,
2397
+ extra: { path: targetDir },
2398
+ label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
2337
2399
  });
2338
- if (hasNext) {
2339
- pageColumns.push({
2340
- tag: 'column',
2341
- width: 'auto',
2342
- vertical_align: 'center',
2343
- elements: [
2344
- {
2345
- tag: 'button',
2346
- text: { tag: 'plain_text', content: '下一页 ➡' },
2347
- type: 'default',
2348
- size: 'small',
2349
- behaviors: [
2350
- {
2351
- type: 'callback',
2352
- value: {
2353
- cmd: 'ls.page',
2354
- path: targetDir,
2355
- offset: offset + CommandRouter.LS_PAGE_SIZE,
2356
- },
2357
- },
2358
- ],
2359
- },
2360
- ],
2361
- });
2362
- }
2363
2400
  elements.push({ tag: 'hr' });
2364
- elements.push({ tag: 'column_set', columns: pageColumns });
2401
+ elements.push(bar);
2365
2402
  }
2366
2403
  return {
2367
2404
  card: {
@@ -2451,7 +2488,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2451
2488
  const totalCount = entries.length;
2452
2489
  const totalPages = Math.max(1, Math.ceil(totalCount / WS_PAGE_SIZE));
2453
2490
  const maxOffset = Math.max(0, (totalPages - 1) * WS_PAGE_SIZE);
2454
- const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
2491
+ const safeOffset = clampInt(offset, 0, maxOffset);
2455
2492
  const currentPage = Math.floor(safeOffset / WS_PAGE_SIZE) + 1;
2456
2493
  const pageEntries = entries.slice(safeOffset, safeOffset + WS_PAGE_SIZE);
2457
2494
  const hasPagination = totalCount > WS_PAGE_SIZE;
@@ -2561,68 +2598,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2561
2598
  // Pagination bar — separate row below the list (not crowded with sort
2562
2599
  // toggle; mobile-friendly vertical layout).
2563
2600
  if (hasPagination) {
2564
- const hasPrev = safeOffset > 0;
2565
- const hasNext = safeOffset + WS_PAGE_SIZE < totalCount;
2566
- const pageColumns = [];
2567
- if (hasPrev) {
2568
- pageColumns.push({
2569
- tag: 'column',
2570
- width: 'auto',
2571
- vertical_align: 'center',
2572
- elements: [
2573
- {
2574
- tag: 'button',
2575
- text: { tag: 'plain_text', content: '⬅' },
2576
- type: 'default',
2577
- size: 'small',
2578
- behaviors: [
2579
- {
2580
- type: 'callback',
2581
- value: { cmd: 'ws.page', offset: safeOffset - WS_PAGE_SIZE },
2582
- },
2583
- ],
2584
- },
2585
- ],
2586
- });
2587
- }
2588
- pageColumns.push({
2589
- tag: 'column',
2590
- width: 'weighted',
2591
- weight: 1,
2592
- vertical_align: 'center',
2593
- elements: [
2594
- {
2595
- tag: 'div',
2596
- text: {
2597
- tag: 'lark_md',
2598
- content: `**${currentPage}/${totalPages}**(${totalCount})`,
2599
- },
2600
- },
2601
- ],
2601
+ const bar = paginationBar({
2602
+ cmd: 'ws.page',
2603
+ offset: safeOffset,
2604
+ pageSize: WS_PAGE_SIZE,
2605
+ total: totalCount,
2606
+ label: `**${currentPage}/${totalPages}**(${totalCount})`,
2607
+ prevText: '⬅',
2608
+ nextText: '➡',
2602
2609
  });
2603
- if (hasNext) {
2604
- pageColumns.push({
2605
- tag: 'column',
2606
- width: 'auto',
2607
- vertical_align: 'center',
2608
- elements: [
2609
- {
2610
- tag: 'button',
2611
- text: { tag: 'plain_text', content: '➡' },
2612
- type: 'default',
2613
- size: 'small',
2614
- behaviors: [
2615
- {
2616
- type: 'callback',
2617
- value: { cmd: 'ws.page', offset: safeOffset + WS_PAGE_SIZE },
2618
- },
2619
- ],
2620
- },
2621
- ],
2622
- });
2623
- }
2624
2610
  bodyElements.push({ tag: 'hr' });
2625
- bodyElements.push({ tag: 'column_set', columns: pageColumns });
2611
+ bodyElements.push(bar);
2626
2612
  }
2627
2613
  }
2628
2614
  const card = {
@@ -2642,6 +2628,36 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2642
2628
  * misleading "未找到". Reuses the same card-builder helpers (markdownDiv) as
2643
2629
  * the normal resume card; enforceCardBudget still applies at send time.
2644
2630
  */
2631
+ /**
2632
+ * /resume 核心实现(public:handleResumePage / handle / resume.use 共用,
2633
+ * 也是测试的直接 seam,避免测试用 @ts-expect-error 摸私有方法)。
2634
+ */
2635
+ /**
2636
+ * DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
2637
+ *
2638
+ * session 的 preset 在创建时固定(§2.1)。通过 listSessions 拿到目标 session 的
2639
+ * agentPreset 与当前配置比对:不一致时不静默复用(返回提示),一致或查询失败
2640
+ * (无法判定)时不阻断,避免 DSH 离线导致 /resume 完全不可用。
2641
+ */
2642
+ dshPresetMismatch(reader, sessionId, cwd) {
2643
+ const currentPreset = this.config.agents?.dsh?.agentPreset;
2644
+ if (!currentPreset)
2645
+ return undefined; // 配置跟随服务端默认,无法判定 → 放行
2646
+ let sessionPreset;
2647
+ try {
2648
+ const { sessions } = reader.listSessions(cwd, { limit: 100 });
2649
+ sessionPreset = sessions.find((s) => s.sessionId === sessionId)?.agentPreset;
2650
+ }
2651
+ catch (err) {
2652
+ getLogger().warn(`[router] dshPresetMismatch listSessions failed for ${sessionId}: ${err.message}`);
2653
+ return undefined;
2654
+ }
2655
+ if (!sessionPreset)
2656
+ return undefined; // 服务端未返回 preset → 无法判定,放行
2657
+ if (sessionPreset === currentPreset)
2658
+ return undefined;
2659
+ return { sessionPreset, currentPreset };
2660
+ }
2645
2661
  cmdResume(args, ctx, offset = 0) {
2646
2662
  const entry = this.sessionStore.get(ctx.userId);
2647
2663
  const cwd = entry?.cwd;
@@ -2688,93 +2704,58 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2688
2704
  text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
2689
2705
  };
2690
2706
  }
2707
+ // DSH preset 一致性校验(§6 风险 1):session 的 preset 在创建时固定,
2708
+ // 若与当前配置不符,直接复用会让后续 run 报 agent-preset-conflict。
2709
+ // 不静默复用——明确提示用户,由用户决定是否继续(配置里手动改/清 preset)。
2710
+ if (agentKind === 'dsh' && this.config.defaultAgent === 'dsh') {
2711
+ const presetMismatch = this.dshPresetMismatch(reader, sessionIdArg, cwd);
2712
+ if (presetMismatch) {
2713
+ return {
2714
+ text: `⚠️ 该 DSH session 的 preset 是「${presetMismatch.sessionPreset}」,与当前配置「${presetMismatch.currentPreset}」不一致。\n` +
2715
+ `preset 在 session 创建时固定,无法中途切换(DSH 返回 agent-preset-conflict)。\n` +
2716
+ `继续恢复将在下次 run 失败。请在 /config 中把预设模式改回「${presetMismatch.sessionPreset}」后重试,或保持当前配置并发新消息新建会话。`,
2717
+ };
2718
+ }
2719
+ }
2691
2720
  // L3: 校验通过后才写入
2692
2721
  this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
2693
2722
  const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
2694
2723
  agentKind,
2695
2724
  maxEvents: AUTO_RESUME_MAX_EVENTS,
2696
2725
  });
2697
- // Build card header with displayTitle (aiTitle or last user message) and recap
2698
- let header = `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`;
2699
- const sections = [];
2700
- if (displayTitle) {
2701
- const label = aiTitle ? 'AI 标题' : '最近输入';
2702
- sections.push(`🏷️ **${label}**\n${displayTitle}`);
2703
- }
2704
- if (recap) {
2705
- const recapPreview = recap.length > 200 ? recap.slice(0, 197) + '...' : recap;
2706
- sections.push(`📝 **Recap**\n${recapPreview}`);
2707
- }
2708
- if (sections.length > 0) {
2709
- header += '\n\n' + sections.join('\n\n──\n\n');
2710
- }
2711
- // Build card with session history
2712
- const elements = [markdownDiv(header), { tag: 'hr' }];
2713
- // Fold session history into collapsible panels: the last 2 events stay
2714
- // expanded so the user sees the most recent context without clicking;
2715
- // older events are collapsed to keep the card compact.
2716
- if (content.length === 0) {
2717
- elements.push(markdownDiv('_该会话暂无新消息可显示(最后一条为用户输入)_'));
2718
- }
2719
- else {
2720
- content.forEach((ev, i) => {
2721
- elements.push(sessionEventPanel(ev, i, content.length, 2, this.config.defaultAgent));
2722
- });
2723
- }
2724
- // Add usage stats at the end
2725
- if (usage) {
2726
- const usageStr = formatUsageStats(usage, { showResult: true, result: 'success' });
2727
- elements.push(markdownDiv(usageStr));
2728
- }
2729
- if (elements.length > 0 && elements[elements.length - 1].tag === 'hr') {
2730
- elements.pop();
2731
- }
2732
- // Add action buttons - use column_set+column for 2.0
2733
- const buttons = [];
2734
- // Stop button for active sessions.
2735
- // isActive is only true when an in-memory activeRun exists, so
2736
- // activeRunRunId is always set here. See buildAutoResumeCard for details.
2737
- if (isActive) {
2738
- buttons.push({
2739
- tag: 'button',
2740
- text: { tag: 'plain_text', content: '⏹ 终止' },
2741
- type: 'danger',
2742
- behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: activeRunRunId, cwd } }],
2743
- });
2744
- }
2745
- // Compact 按钮:仅 codex app-server 模式(runner 有 runCompact)且会话未在跑。
2746
- // agentKind 来自 /resume [agent] <id> / resume.use 卡片值,非 defaultAgent。
2747
- if (!isActive && this.canCompactSession(agentKind)) {
2748
- buttons.push(resumeCompactButton(sessionIdArg, agentKind));
2749
- }
2750
- if (buttons.length > 0) {
2751
- elements.push({
2752
- tag: 'column_set',
2753
- columns: buttons.map((btn) => ({
2754
- tag: 'column',
2755
- width: 'auto',
2756
- elements: [btn],
2757
- })),
2758
- });
2759
- }
2760
- const card = {
2761
- schema: '2.0',
2762
- config: { wide_screen_mode: true },
2763
- header: {
2764
- title: {
2765
- tag: 'plain_text',
2766
- content: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
2767
- },
2768
- },
2769
- body: { elements },
2770
- };
2726
+ // Action buttons: stop (active) + compact (capable runner, not running) + new session
2727
+ const actions = this.buildResumeActionButtons({
2728
+ isActive,
2729
+ activeRunRunId,
2730
+ cwd,
2731
+ compact: this.canCompactSession(agentKind, cwd)
2732
+ ? { sessionId: sessionIdArg, agentKind }
2733
+ : undefined,
2734
+ newSession: true,
2735
+ });
2736
+ const card = buildSessionHistoryCard({
2737
+ sessionId: sessionIdArg,
2738
+ cwd,
2739
+ displayTitle,
2740
+ aiTitle,
2741
+ recap,
2742
+ events: content,
2743
+ usage,
2744
+ }, {
2745
+ agentKind,
2746
+ headerText: `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`,
2747
+ title: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
2748
+ usageResult: 'success',
2749
+ emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
2750
+ actions,
2751
+ });
2771
2752
  return { card };
2772
2753
  }
2773
2754
  // `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
2774
2755
  // Use agentKind to select the appropriate sessionReader
2775
2756
  if (!cwd)
2776
2757
  return { text: '请先 /cd 设置工作目录' };
2777
- const pageSize = Math.min(Math.max(limit ?? RESUME_PAGE_SIZE, 1), RESUME_PAGE_SIZE);
2758
+ const pageSize = clampInt(limit ?? RESUME_PAGE_SIZE, 1, RESUME_PAGE_SIZE);
2778
2759
  let reader;
2779
2760
  // Use this.sessionReader when agentKind matches default, otherwise get from registry
2780
2761
  if (agentKind === this.config.defaultAgent) {
@@ -2794,7 +2775,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2794
2775
  // 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
2795
2776
  const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
2796
2777
  if (pageOffset < 0 || pageOffset > maxOffset) {
2797
- pageOffset = Math.min(Math.max(pageOffset, 0), maxOffset);
2778
+ pageOffset = clampInt(pageOffset, 0, maxOffset);
2798
2779
  pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
2799
2780
  }
2800
2781
  }
@@ -2918,80 +2899,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2918
2899
  if (total > pageSize) {
2919
2900
  const totalPages = Math.ceil(total / pageSize);
2920
2901
  const currentPage = Math.floor(pageOffset / pageSize) + 1;
2921
- const hasPrev = pageOffset > 0;
2922
- const hasNext = pageOffset + pageSize < total;
2923
- // Build columns dynamically — Feishu rejects empty elements[] in a column
2924
- // (ErrCode 200621: "no tag specified"). Only include columns that have content.
2925
- const pageColumns = [];
2926
- if (hasPrev) {
2927
- pageColumns.push({
2928
- tag: 'column',
2929
- width: 'auto',
2930
- vertical_align: 'center',
2931
- elements: [
2932
- {
2933
- tag: 'button',
2934
- text: { tag: 'plain_text', content: '⬅ 上一页' },
2935
- type: 'default',
2936
- size: 'small',
2937
- behaviors: [
2938
- {
2939
- type: 'callback',
2940
- value: {
2941
- cmd: 'resume.page',
2942
- agent: agentKind,
2943
- offset: pageOffset - pageSize,
2944
- pageSize,
2945
- },
2946
- },
2947
- ],
2948
- },
2949
- ],
2950
- });
2951
- }
2952
- pageColumns.push({
2953
- tag: 'column',
2954
- width: 'weighted',
2955
- weight: 1,
2956
- vertical_align: 'center',
2957
- elements: [
2958
- {
2959
- tag: 'div',
2960
- text: {
2961
- tag: 'lark_md',
2962
- content: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
2963
- },
2964
- },
2965
- ],
2902
+ const bar = paginationBar({
2903
+ cmd: 'resume.page',
2904
+ offset: pageOffset,
2905
+ pageSize,
2906
+ total,
2907
+ extra: { agent: agentKind, pageSize },
2908
+ label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
2966
2909
  });
2967
- if (hasNext) {
2968
- pageColumns.push({
2969
- tag: 'column',
2970
- width: 'auto',
2971
- vertical_align: 'center',
2972
- elements: [
2973
- {
2974
- tag: 'button',
2975
- text: { tag: 'plain_text', content: '下一页 ➡' },
2976
- type: 'default',
2977
- size: 'small',
2978
- behaviors: [
2979
- {
2980
- type: 'callback',
2981
- value: {
2982
- cmd: 'resume.page',
2983
- agent: agentKind,
2984
- offset: pageOffset + pageSize,
2985
- pageSize,
2986
- },
2987
- },
2988
- ],
2989
- },
2990
- ],
2991
- });
2992
- }
2993
2910
  elements.push({ tag: 'hr' });
2994
- elements.push({ tag: 'column_set', columns: pageColumns });
2911
+ elements.push(bar);
2995
2912
  }
2996
2913
  const card = {
2997
2914
  schema: '2.0',
@@ -3159,6 +3076,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3159
3076
  async cmdConfig(args, ctx) {
3160
3077
  // Ensure probe cache is populated before building the card.
3161
3078
  await probeAllAgents();
3079
+ // 动态清单预取(DSH 模型/预设目录):卡渲染前拉取,失败回退兜底清单
3080
+ await this.prefetchConfigCardCatalogs();
3162
3081
  // Check if this is a direct set command: /config <key> <value>
3163
3082
  // Command-style set: immediate write to disk, clear pendingConfig
3164
3083
  if (args.length >= 2) {
@@ -3205,6 +3124,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3205
3124
  this.ensurePendingConfig();
3206
3125
  return this.buildConfigCard();
3207
3126
  }
3127
+ /**
3128
+ * DSH /config 卡片构建前异步预取模型/预设目录(§4.2 动态清单)。
3129
+ * 只在 defaultAgent 为 dsh 时生效;其他 agent 无 prefetch 钩子直接跳过。
3130
+ * 预取失败不阻断——DshConfigBuilder 内部已回退固定兜底清单。
3131
+ */
3132
+ async prefetchConfigCardCatalogs() {
3133
+ if (this.config.defaultAgent !== 'dsh')
3134
+ return;
3135
+ const builder = getConfigBuilder('dsh');
3136
+ const dshConf = getAgentConfig(this.config, 'dsh');
3137
+ await builder.prefetch?.(dshConf?.host);
3138
+ }
3208
3139
  /** Build an interactive config card with CardKit 2.0 tabs + batch save */
3209
3140
  buildConfigCard() {
3210
3141
  // 使用 pendingConfig(若存在)或当前 config
@@ -3219,7 +3150,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3219
3150
  // Direct inline construction (not via ConfigField) so that text (display label)
3220
3151
  // and value (agentKind for config storage) can differ — uninstalled agents get
3221
3152
  // a "⚠️ (未安装)" suffix in the label while the value stays clean.
3222
- const allAgents = listRegisteredAgents();
3153
+ // 固定展示顺序(Codex → Claude → OpenCode → Pi → Kimi),
3154
+ // 明确未安装的 agent 排到后面(getCachedAvailability === false)。
3155
+ const allAgents = sortAgentsForDisplay(listRegisteredAgents(), getCachedAvailability);
3223
3156
  const agentOptions = allAgents.map((kind) => {
3224
3157
  const available = getCachedAvailability(kind);
3225
3158
  const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
@@ -3313,11 +3246,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3313
3246
  }
3314
3247
  return { card };
3315
3248
  }
3249
+ /** /order 实现(public:测试直接调用,替代 as unknown as)。 */
3316
3250
  cmdOrder(args, _ctx, offset = 0) {
3317
3251
  const sub = args[0]?.toLowerCase();
3318
- // /order — list orders (CardKit 2.0 with pagination)
3319
- if (!sub || sub === 'list') {
3252
+ // /order — list orders + aliases (CardKit 2.0 with pagination)
3253
+ // `/order alias`(无参数)也并入列表卡片。
3254
+ if (!sub || sub === 'list' || (sub === 'alias' && !args[1])) {
3320
3255
  this.orderStore.reload();
3256
+ this.aliasStore.reload();
3321
3257
  const allOrders = this.orderStore.get();
3322
3258
  const elements = [];
3323
3259
  if (allOrders.length === 0) {
@@ -3329,7 +3265,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3329
3265
  const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
3330
3266
  // Clamp offset to last page boundary to avoid empty pages
3331
3267
  const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
3332
- const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
3268
+ const safeOffset = clampInt(offset, 0, maxOffset);
3333
3269
  const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
3334
3270
  const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
3335
3271
  const hasPagination = totalCount > ORDER_PAGE_SIZE;
@@ -3384,74 +3320,31 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3384
3320
  }
3385
3321
  // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
3386
3322
  if (hasPagination) {
3387
- const hasPrev = safeOffset > 0;
3388
- const hasNext = safeOffset + ORDER_PAGE_SIZE < totalCount;
3389
- const pageColumns = [];
3390
- if (hasPrev) {
3391
- pageColumns.push({
3392
- tag: 'column',
3393
- width: 'auto',
3394
- vertical_align: 'center',
3395
- elements: [
3396
- {
3397
- tag: 'button',
3398
- text: { tag: 'plain_text', content: '⬅ 上一页' },
3399
- type: 'default',
3400
- size: 'small',
3401
- behaviors: [
3402
- {
3403
- type: 'callback',
3404
- value: {
3405
- cmd: 'order.page',
3406
- offset: safeOffset - ORDER_PAGE_SIZE,
3407
- },
3408
- },
3409
- ],
3410
- },
3411
- ],
3412
- });
3413
- }
3414
- pageColumns.push({
3415
- tag: 'column',
3416
- width: 'weighted',
3417
- weight: 1,
3418
- vertical_align: 'center',
3419
- elements: [
3420
- {
3421
- tag: 'div',
3422
- text: {
3423
- tag: 'lark_md',
3424
- content: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
3425
- },
3426
- },
3427
- ],
3323
+ const bar = paginationBar({
3324
+ cmd: 'order.page',
3325
+ offset: safeOffset,
3326
+ pageSize: ORDER_PAGE_SIZE,
3327
+ total: totalCount,
3328
+ label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
3428
3329
  });
3429
- if (hasNext) {
3430
- pageColumns.push({
3431
- tag: 'column',
3432
- width: 'auto',
3433
- vertical_align: 'center',
3434
- elements: [
3435
- {
3436
- tag: 'button',
3437
- text: { tag: 'plain_text', content: '下一页 ➡' },
3438
- type: 'default',
3439
- size: 'small',
3440
- behaviors: [
3441
- {
3442
- type: 'callback',
3443
- value: {
3444
- cmd: 'order.page',
3445
- offset: safeOffset + ORDER_PAGE_SIZE,
3446
- },
3447
- },
3448
- ],
3449
- },
3450
- ],
3451
- });
3452
- }
3453
3330
  elements.push({ tag: 'hr' });
3454
- elements.push({ tag: 'column_set', columns: pageColumns });
3331
+ elements.push(bar);
3332
+ }
3333
+ }
3334
+ // 别名区:并入 /order 卡片(`/order alias` 列出全部)。
3335
+ const aliases = this.aliasStore.list();
3336
+ if (aliases.length > 0) {
3337
+ elements.push({ tag: 'hr' });
3338
+ elements.push({
3339
+ tag: 'div',
3340
+ text: { tag: 'lark_md', content: '**⚡ 别名**(输入 $name 触发)' },
3341
+ });
3342
+ for (const alias of aliases) {
3343
+ const text = alias.text.length > 100 ? alias.text.slice(0, 97) + '...' : alias.text;
3344
+ elements.push({
3345
+ tag: 'div',
3346
+ text: { tag: 'lark_md', content: `$${alias.name} → ${text}` },
3347
+ });
3455
3348
  }
3456
3349
  }
3457
3350
  return {
@@ -3463,6 +3356,47 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3463
3356
  },
3464
3357
  };
3465
3358
  }
3359
+ // /order alias <name> <text> — 注册;/order alias <name> — 查看;/order alias remove <name> — 删除
3360
+ if (sub === 'alias') {
3361
+ const name = args[1];
3362
+ if (name === 'remove') {
3363
+ const target = args[2];
3364
+ if (!target) {
3365
+ return { text: '用法: /order alias remove <name>' };
3366
+ }
3367
+ // remove 是保留子命令,不能作为别名名:后面跟多词文本说明用户想注册
3368
+ // 名为 remove 的别名,明确报错而不是静默删除已存在的 target(P2 review)。
3369
+ if (args.length > 3) {
3370
+ return { text: '`remove` 是保留子命令,不能作为别名名,请换一个名称' };
3371
+ }
3372
+ const entry = this.aliasStore.get(target);
3373
+ if (!entry) {
3374
+ return { text: `别名 $${target} 不存在` };
3375
+ }
3376
+ this.aliasStore.remove(target);
3377
+ // 回显原内容:单词场景无法区分「注册 remove」与「删除」,误删后
3378
+ // 用户可直接按回显内容重新注册,消除静默破坏(P3 review)。
3379
+ const preview = entry.text.length > 50 ? entry.text.slice(0, 50) + '...' : entry.text;
3380
+ return { text: `✅ 已删除别名 $${target}(原内容:${preview})` };
3381
+ }
3382
+ const text = args.slice(2).join(' ');
3383
+ if (!text) {
3384
+ const entry = name ? this.aliasStore.get(name) : undefined;
3385
+ if (!entry) {
3386
+ return { text: name ? `别名 $${name} 不存在` : '用法: /order alias <name> <text>' };
3387
+ }
3388
+ return { text: `$${entry.name} → ${entry.text}` };
3389
+ }
3390
+ try {
3391
+ const existed = this.aliasStore.has(name);
3392
+ this.aliasStore.set(name, text);
3393
+ const preview = text.length > 50 ? text.slice(0, 50) + '...' : text;
3394
+ return { text: `${existed ? '✅ 已更新别名' : '✅ 已保存别名'} $${name} → ${preview}` };
3395
+ }
3396
+ catch (err) {
3397
+ return { text: `保存失败: ${err.message}` };
3398
+ }
3399
+ }
3466
3400
  // /order save <text>
3467
3401
  if (sub === 'save') {
3468
3402
  const text = args.slice(1).join(' ');
@@ -3477,10 +3411,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3477
3411
  return { text: `保存失败: ${err.message}` };
3478
3412
  }
3479
3413
  }
3480
- return { text: '用法: /order [list|save <text>]' };
3414
+ return { text: '用法: /order [list|save <text>|alias <name> <text>|alias remove <name>]' };
3481
3415
  }
3482
3416
  }
3483
- // --- Re-exported from ./utils.js (moved for decomposition) ---
3484
- // formatUsageStats is shared by run-renderer and bridge via ../router/index.js.
3485
- export { formatUsageStats } from './utils.js';
3486
3417
  //# sourceMappingURL=index.js.map