lark-remote 0.1.6 → 0.1.7

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 (188) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +9 -1
  3. package/README.md +14 -2
  4. package/dist/bridge/approval-coordinator.d.ts +57 -34
  5. package/dist/bridge/approval-coordinator.js +191 -40
  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 +78 -7
  9. package/dist/bridge/index.js +202 -79
  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 +152 -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 +1 -12
  20. package/dist/card/card-shared.js +1 -26
  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.js +99 -232
  24. package/dist/card/run-state.d.ts +13 -0
  25. package/dist/card/run-state.js +25 -16
  26. package/dist/card/text-truncate.d.ts +13 -12
  27. package/dist/card/text-truncate.js +13 -63
  28. package/dist/common/fs.d.ts +8 -0
  29. package/dist/common/fs.js +17 -0
  30. package/dist/common/guards.d.ts +0 -20
  31. package/dist/common/guards.js +0 -43
  32. package/dist/common/sleep.d.ts +2 -0
  33. package/dist/common/sleep.js +5 -0
  34. package/dist/common/truncate.d.ts +39 -0
  35. package/dist/common/truncate.js +92 -0
  36. package/dist/config/codex-config.d.ts +1 -1
  37. package/dist/config/codex-config.js +1 -1
  38. package/dist/config/index.d.ts +88 -23
  39. package/dist/config/index.js +105 -34
  40. package/dist/connector/index.d.ts +69 -2
  41. package/dist/connector/index.js +107 -1
  42. package/dist/index.js +123 -105
  43. package/dist/instance-lock.js +4 -12
  44. package/dist/order/alias-resolve.d.ts +13 -0
  45. package/dist/order/alias-resolve.js +22 -0
  46. package/dist/order/alias-store.d.ts +38 -0
  47. package/dist/order/alias-store.js +106 -0
  48. package/dist/persistence/atomic-write.d.ts +8 -2
  49. package/dist/persistence/atomic-write.js +23 -7
  50. package/dist/restart.js +2 -1
  51. package/dist/router/card-helpers.d.ts +63 -0
  52. package/dist/router/card-helpers.js +146 -0
  53. package/dist/router/config/claude.js +25 -1
  54. package/dist/router/config/codex.js +29 -50
  55. package/dist/router/config/common/model-patch.d.ts +22 -0
  56. package/dist/router/config/common/model-patch.js +26 -0
  57. package/dist/router/config/common/render.js +5 -4
  58. package/dist/router/config/index.d.ts +12 -0
  59. package/dist/router/config/index.js +25 -1
  60. package/dist/router/config/kimi.d.ts +4 -1
  61. package/dist/router/config/kimi.js +27 -0
  62. package/dist/router/config/opencode.js +18 -5
  63. package/dist/router/config/pi.js +4 -5
  64. package/dist/router/config/types.d.ts +11 -2
  65. package/dist/router/index.d.ts +56 -21
  66. package/dist/router/index.js +373 -548
  67. package/dist/router/utils.d.ts +2 -0
  68. package/dist/router/utils.js +4 -0
  69. package/dist/runner/agent-choices-common.d.ts +16 -0
  70. package/dist/runner/agent-choices-common.js +31 -0
  71. package/dist/runner/claude/runner.d.ts +47 -10
  72. package/dist/runner/claude/runner.js +117 -46
  73. package/dist/runner/claude/session.d.ts +166 -0
  74. package/dist/runner/claude/session.js +749 -0
  75. package/dist/runner/codex/app-server/protocol-types.d.ts +31 -39
  76. package/dist/runner/codex/app-server/protocol-types.js +1 -12
  77. package/dist/runner/codex/app-server/runner.d.ts +42 -59
  78. package/dist/runner/codex/app-server/runner.js +160 -337
  79. package/dist/runner/codex/app-server/translator.d.ts +1 -21
  80. package/dist/runner/codex/app-server/translator.js +0 -18
  81. package/dist/runner/codex/index.d.ts +0 -2
  82. package/dist/runner/codex/index.js +0 -2
  83. package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
  84. package/dist/runner/common/acp/protocol-helpers.js +21 -0
  85. package/dist/runner/common/acp/protocol-types.d.ts +238 -0
  86. package/dist/runner/common/acp/protocol-types.js +75 -0
  87. package/dist/runner/common/connection-based-runner.d.ts +111 -0
  88. package/dist/runner/common/connection-based-runner.js +261 -0
  89. package/dist/runner/{codex/app-server → common/jsonrpc}/client.d.ts +14 -7
  90. package/dist/runner/{codex/app-server → common/jsonrpc}/client.js +27 -18
  91. package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
  92. package/dist/runner/{codex/app-server → common/jsonrpc}/connection-manager.js +63 -28
  93. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.d.ts +5 -4
  94. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.js +18 -19
  95. package/dist/runner/common/jsonrpc/types.d.ts +32 -0
  96. package/dist/runner/common/jsonrpc/types.js +9 -0
  97. package/dist/runner/common/process-stopper.d.ts +2 -1
  98. package/dist/runner/common/process-stopper.js +1 -0
  99. package/dist/runner/common/runner-utils.d.ts +6 -13
  100. package/dist/runner/common/runner-utils.js +6 -47
  101. package/dist/runner/common/spawning-runner.d.ts +24 -70
  102. package/dist/runner/common/spawning-runner.js +98 -152
  103. package/dist/runner/index.d.ts +4 -4
  104. package/dist/runner/index.js +4 -4
  105. package/dist/runner/kimi/acp/runner.d.ts +106 -0
  106. package/dist/runner/kimi/acp/runner.js +448 -0
  107. package/dist/runner/kimi/acp/translator.d.ts +124 -0
  108. package/dist/runner/kimi/acp/translator.js +336 -0
  109. package/dist/runner/kimi/index.d.ts +1 -1
  110. package/dist/runner/kimi/index.js +1 -1
  111. package/dist/runner/opencode/acp/index.d.ts +1 -0
  112. package/dist/runner/opencode/acp/index.js +2 -0
  113. package/dist/runner/opencode/acp/runner.d.ts +121 -0
  114. package/dist/runner/opencode/acp/runner.js +429 -0
  115. package/dist/runner/opencode/acp/translator.d.ts +119 -0
  116. package/dist/runner/opencode/acp/translator.js +345 -0
  117. package/dist/runner/opencode/index.d.ts +1 -1
  118. package/dist/runner/opencode/index.js +1 -1
  119. package/dist/runner/pi/index.d.ts +1 -1
  120. package/dist/runner/pi/index.js +1 -1
  121. package/dist/runner/pi/rpc/client.d.ts +52 -0
  122. package/dist/runner/pi/rpc/client.js +114 -0
  123. package/dist/runner/pi/rpc/index.d.ts +4 -0
  124. package/dist/runner/pi/rpc/index.js +4 -0
  125. package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
  126. package/dist/runner/pi/rpc/protocol-types.js +21 -0
  127. package/dist/runner/pi/rpc/runner.d.ts +80 -0
  128. package/dist/runner/pi/rpc/runner.js +199 -0
  129. package/dist/runner/pi/rpc/translator.d.ts +55 -0
  130. package/dist/runner/pi/rpc/translator.js +258 -0
  131. package/dist/runner/registry.d.ts +7 -8
  132. package/dist/runner/registry.js +3 -4
  133. package/dist/runner/resolve-agent-choices.js +18 -59
  134. package/dist/runner/sync-agent-choices.js +16 -67
  135. package/dist/runner/types.d.ts +43 -13
  136. package/dist/session/claude/session-index.js +2 -3
  137. package/dist/session/claude/session-reader.js +3 -6
  138. package/dist/session/claude/sessions.js +6 -13
  139. package/dist/session/codex/rollout-reader.d.ts +2 -2
  140. package/dist/session/codex/rollout-reader.js +14 -17
  141. package/dist/session/codex/sessions.d.ts +1 -1
  142. package/dist/session/codex/sessions.js +2 -2
  143. package/dist/session/common/content-blocks.d.ts +7 -0
  144. package/dist/session/common/content-blocks.js +13 -9
  145. package/dist/session/common/jsonl.d.ts +8 -25
  146. package/dist/session/common/jsonl.js +126 -261
  147. package/dist/session/common/pagination.d.ts +28 -0
  148. package/dist/session/common/pagination.js +33 -0
  149. package/dist/session/kimi/sessions.d.ts +34 -1
  150. package/dist/session/kimi/sessions.js +208 -98
  151. package/dist/session/opencode/sessions.d.ts +17 -3
  152. package/dist/session/opencode/sessions.js +29 -29
  153. package/dist/session/pi/sessions.d.ts +5 -1
  154. package/dist/session/pi/sessions.js +12 -16
  155. package/dist/session/session-store.d.ts +11 -1
  156. package/dist/session/session-store.js +75 -178
  157. package/dist/update/index.d.ts +2 -2
  158. package/dist/update/index.js +2 -2
  159. package/dist/update/install.d.ts +20 -2
  160. package/dist/update/install.js +51 -7
  161. package/package.json +1 -1
  162. package/dist/runner/codex/app-server/connection-manager.d.ts +0 -56
  163. package/dist/runner/codex/argv.d.ts +0 -33
  164. package/dist/runner/codex/argv.js +0 -40
  165. package/dist/runner/codex/factory.d.ts +0 -29
  166. package/dist/runner/codex/factory.js +0 -39
  167. package/dist/runner/codex/jsonl.d.ts +0 -37
  168. package/dist/runner/codex/jsonl.js +0 -221
  169. package/dist/runner/codex/runner.d.ts +0 -66
  170. package/dist/runner/codex/runner.js +0 -129
  171. package/dist/runner/common/exec-translator.d.ts +0 -73
  172. package/dist/runner/common/exec-translator.js +0 -105
  173. package/dist/runner/kimi/jsonl.d.ts +0 -28
  174. package/dist/runner/kimi/jsonl.js +0 -143
  175. package/dist/runner/kimi/runner.d.ts +0 -49
  176. package/dist/runner/kimi/runner.js +0 -105
  177. package/dist/runner/opencode/argv.d.ts +0 -32
  178. package/dist/runner/opencode/argv.js +0 -35
  179. package/dist/runner/opencode/jsonl.d.ts +0 -68
  180. package/dist/runner/opencode/jsonl.js +0 -295
  181. package/dist/runner/opencode/runner.d.ts +0 -61
  182. package/dist/runner/opencode/runner.js +0 -93
  183. package/dist/runner/pi/jsonl.d.ts +0 -128
  184. package/dist/runner/pi/jsonl.js +0 -249
  185. package/dist/runner/pi/runner.d.ts +0 -36
  186. package/dist/runner/pi/runner.js +0 -76
  187. package/dist/session/common/catch-up.d.ts +0 -12
  188. 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, } 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,24 @@ 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');
105
125
  }
106
126
  export class CommandRouter {
107
127
  /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
108
128
  static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
109
129
  sessionStore;
130
+ /** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
110
131
  bridge;
132
+ /** 当前配置(public:测试直接读取断言,替代 as unknown as)。 */
111
133
  config;
134
+ /** 配置文件的绝对路径(public:/config 保存链路与测试共用)。 */
112
135
  configPath;
113
136
  workspaceStore;
114
137
  orderStore;
138
+ aliasStore;
115
139
  exitHandler;
116
140
  pendingExit = false;
117
141
  /**
@@ -126,7 +150,8 @@ export class CommandRouter {
126
150
  updateCachePath;
127
151
  /** Injected update functions (for testability; defaults to real implementation). */
128
152
  updateFns;
129
- pendingConfig = null; // /config 卡片编辑暂存区
153
+ /** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
154
+ pendingConfig = null;
130
155
  /**
131
156
  * Monotonic counter minting unique internal keys for order.exec enqueue
132
157
  * actions. One order card can be clicked many times, so the Feishu card
@@ -147,6 +172,7 @@ export class CommandRouter {
147
172
  * it uses `listRegisteredAgents()` from `router/config/index.ts` (the
148
173
  * config-builder registry), which mirrors the agents with a config card.
149
174
  */
175
+ /** Session reader 注册中心(public:测试注入/读取,替代 as unknown as)。 */
150
176
  sessionReaderRegistry;
151
177
  /**
152
178
  * 串行化 config.* 卡片回调(2026-07-04)。
@@ -164,6 +190,7 @@ export class CommandRouter {
164
190
  this.configPath = opts.configPath;
165
191
  this.workspaceStore = new WorkspaceStore(opts.workspacePath);
166
192
  this.orderStore = new OrderStore(opts.ordersPath);
193
+ this.aliasStore = new AliasStore(opts.aliasesPath);
167
194
  this.exitHandler = opts.exitHandler ?? (() => process.exit(0));
168
195
  this.restartSpawner = opts.restartSpawner;
169
196
  // idle watchdog 窗口从 config.idle.watchdogMinutes 读取
@@ -202,6 +229,8 @@ export class CommandRouter {
202
229
  await this.bridge.sendResult(result, ctx);
203
230
  }
204
231
  if (this.pendingExit) {
232
+ // 干净退出前冲刷待合批的媒体保存提示,避免 500ms 窗口内的提示丢失。
233
+ await this.bridge.flushAllMediaNotifications();
205
234
  this.exitHandler();
206
235
  }
207
236
  return result;
@@ -222,6 +251,13 @@ export class CommandRouter {
222
251
  await this.bridge.forwardToClaude(trimmed, ctx, opts);
223
252
  return null;
224
253
  }
254
+ /**
255
+ * 一次别名展开:bridge 收到用户消息后、命令分发前调用。
256
+ * 仅 `$name` 开头的消息可能被展开;未知别名 / 非 `$` 消息原样返回。
257
+ */
258
+ expandAliasMessage(message) {
259
+ return resolveAlias(message, this.aliasStore.list()) ?? message;
260
+ }
225
261
  /**
226
262
  * Handle a CardKit card button click (§6.2).
227
263
  * Validates payload and dispatches to the corresponding command.
@@ -326,6 +362,9 @@ export class CommandRouter {
326
362
  return this.enqueueConfigAction(value, ctx);
327
363
  case 'approval.respond':
328
364
  case 'approval.toggle':
365
+ case 'approval.answer':
366
+ case 'approval.answerSubmit':
367
+ case 'approval.answerCustom':
329
368
  return this.handleApprovalAction(value, ctx);
330
369
  case 'codex.compact':
331
370
  await this.bridge.handleCodexCompact(value, ctx);
@@ -346,6 +385,7 @@ export class CommandRouter {
346
385
  // 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
347
386
  // 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
348
387
  if (this.pendingExit) {
388
+ await this.bridge.flushAllMediaNotifications();
349
389
  this.exitHandler();
350
390
  }
351
391
  return;
@@ -364,7 +404,7 @@ export class CommandRouter {
364
404
  * Returns a toast response for immediate user feedback.
365
405
  */
366
406
  async handleApprovalAction(value, _ctx) {
367
- const { runId, requestId, decision, scope, nonce, permId } = value;
407
+ const { runId, requestId, decision, nonce, permId } = value;
368
408
  if (value.cmd === 'approval.respond') {
369
409
  if (!runId || requestId === undefined || !decision || !nonce) {
370
410
  return { toast: { type: 'error', content: '缺少审批响应参数' } };
@@ -374,7 +414,6 @@ export class CommandRouter {
374
414
  runId,
375
415
  requestId,
376
416
  decision,
377
- scope,
378
417
  nonce,
379
418
  });
380
419
  return { toast: { type: 'success', content: '审批已提交' } };
@@ -389,7 +428,7 @@ export class CommandRouter {
389
428
  }
390
429
  }
391
430
  if (value.cmd === 'approval.toggle') {
392
- if (!runId || requestId === undefined || !permId || !nonce) {
431
+ if (!runId || requestId === undefined || !permId) {
393
432
  return { toast: { type: 'error', content: '缺少权限切换参数' } };
394
433
  }
395
434
  try {
@@ -397,7 +436,6 @@ export class CommandRouter {
397
436
  runId,
398
437
  requestId,
399
438
  permId,
400
- nonce,
401
439
  selected: value.selected ?? true,
402
440
  });
403
441
  return { toast: { type: 'info', content: '已切换' } };
@@ -407,6 +445,70 @@ export class CommandRouter {
407
445
  return { toast: { type: 'error', content: `权限切换失败:${msg}` } };
408
446
  }
409
447
  }
448
+ if (value.cmd === 'approval.answer') {
449
+ if (!runId ||
450
+ requestId === undefined ||
451
+ value.questionIndex === undefined ||
452
+ !value.option ||
453
+ !nonce) {
454
+ return { toast: { type: 'error', content: '缺少问题答案参数' } };
455
+ }
456
+ try {
457
+ await this.bridge.handleApprovalAnswer({
458
+ runId,
459
+ requestId,
460
+ questionIndex: value.questionIndex,
461
+ option: value.option,
462
+ nonce,
463
+ });
464
+ return { toast: { type: 'success', content: '已选择' } };
465
+ }
466
+ catch (err) {
467
+ const msg = err instanceof Error ? err.message : String(err);
468
+ return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
469
+ }
470
+ }
471
+ if (value.cmd === 'approval.answerSubmit') {
472
+ if (!runId || requestId === undefined || value.questionIndex === undefined || !nonce) {
473
+ return { toast: { type: 'error', content: '缺少提交参数' } };
474
+ }
475
+ try {
476
+ await this.bridge.handleApprovalAnswerSubmit({
477
+ runId,
478
+ requestId,
479
+ questionIndex: value.questionIndex,
480
+ nonce,
481
+ });
482
+ return { toast: { type: 'success', content: '答案已提交' } };
483
+ }
484
+ catch (err) {
485
+ const msg = err instanceof Error ? err.message : String(err);
486
+ return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
487
+ }
488
+ }
489
+ if (value.cmd === 'approval.answerCustom') {
490
+ if (!runId ||
491
+ requestId === undefined ||
492
+ value.questionIndex === undefined ||
493
+ !nonce ||
494
+ !value.inputValue) {
495
+ return { toast: { type: 'error', content: '缺少答案文本参数' } };
496
+ }
497
+ try {
498
+ await this.bridge.handleApprovalAnswerCustom({
499
+ runId,
500
+ requestId,
501
+ questionIndex: value.questionIndex,
502
+ text: value.inputValue,
503
+ nonce,
504
+ });
505
+ return { toast: { type: 'success', content: '答案已提交' } };
506
+ }
507
+ catch (err) {
508
+ const msg = err instanceof Error ? err.message : String(err);
509
+ return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
510
+ }
511
+ }
410
512
  return { toast: { type: 'error', content: '未知的审批操作' } };
411
513
  }
412
514
  /**
@@ -888,17 +990,7 @@ export class CommandRouter {
888
990
  const resolvedAgent = validAgents.includes(resumeAgent)
889
991
  ? resumeAgent
890
992
  : 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;
993
+ const { pageSize, alignedOffset } = normalizePageArgs(value, RESUME_PAGE_SIZE);
902
994
  const entry = this.sessionStore.get(ctx.userId);
903
995
  const cwd = entry?.cwd;
904
996
  if (!cwd) {
@@ -925,9 +1017,7 @@ export class CommandRouter {
925
1017
  * Mirrors handleResumePage — updates the same card, never sends a new one.
926
1018
  */
927
1019
  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;
1020
+ const { alignedOffset } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
931
1021
  const result = this.cmdActive([], ctx, alignedOffset);
932
1022
  if (!result.card) {
933
1023
  return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
@@ -1054,7 +1144,7 @@ export class CommandRouter {
1054
1144
  // immediate action the callback response is swallowed by enqueueImmediate
1055
1145
  // (fire-and-forget), and even if delivered it is transient — the user gets
1056
1146
  // no perceivable feedback on the switch. Same rationale as config.save
1057
- // agent-switch notice: persistent message first, toast only as a
1147
+ // agent-switch notice (74e99f0): persistent message first, toast only as a
1058
1148
  // fallback if sendResult fails.
1059
1149
  let fallbackToast;
1060
1150
  if (useResult.card || useResult.text) {
@@ -1186,6 +1276,7 @@ export class CommandRouter {
1186
1276
  return next;
1187
1277
  }
1188
1278
  /** 实际的 config.* 分发逻辑(在串行队列内执行) */
1279
+ /** config.* 卡片动作分发(public:测试直接调用,替代 as unknown as)。 */
1189
1280
  async dispatchConfigAction(value, ctx) {
1190
1281
  switch (value.cmd) {
1191
1282
  case 'config.toggle': {
@@ -1314,37 +1405,29 @@ export class CommandRouter {
1314
1405
  }
1315
1406
  }
1316
1407
  /** 设置嵌套属性值(仅修改内存对象,不写盘) */
1408
+ /** 在 pendingConfig 上按 dot-separated key 设置嵌套值(config 卡片编辑用)。 */
1317
1409
  setNestedValue(target, key, value) {
1318
1410
  // Path mapping 共用 config 模块的 mapAgentKey(G11 Inconsistency 修复):
1319
1411
  // pi.xxx/codex.xxx/opencode.xxx → agents.xxx,claude.xxx 保持顶层
1320
1412
  const mappedKey = mapAgentKey(key);
1321
1413
  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]);
1414
+ // 复用 config 模块的 walkNestedContainer(含 __proto__/prototype/constructor
1415
+ // 守卫与中间段补 {}),router 只保留 value=undefined → delete 分支。
1416
+ const container = walkNestedContainer(target, parts, true);
1417
+ const lastPart = parts[parts.length - 1];
1418
+ assertSafeKeyPart(lastPart);
1337
1419
  if (value === undefined) {
1338
1420
  // value=undefined 表示"删除键"(如清空 reasoningEffort),
1339
1421
  // 不能写成 undefined 值——diffConfig 会把 undefined 转成字面量 "undefined"
1340
1422
  // 写入 config.yaml 并透传给 codex(ReasoningEffort::Custom("undefined"))。
1341
- delete current[parts[parts.length - 1]];
1423
+ delete container[lastPart];
1342
1424
  }
1343
1425
  else {
1344
- current[parts[parts.length - 1]] = value;
1426
+ container[lastPart] = value;
1345
1427
  }
1346
1428
  }
1347
1429
  /** 对比原始 config 与 pendingConfig,返回变化的 key→string 映射 */
1430
+ /** config diff(public:测试直接调用,替代 as unknown as)。 */
1348
1431
  diffConfig(original, pending) {
1349
1432
  const updates = {};
1350
1433
  this.collectDiff('', original, pending, updates);
@@ -1480,6 +1563,27 @@ export class CommandRouter {
1480
1563
  atomicWrite(this.configPath, YAML.stringify(this.config));
1481
1564
  this.bridge.setConfig(this.config);
1482
1565
  }
1566
+ // P1-6 + live approval settings (§P5): active workspace-lifetime runners
1567
+ // are not evicted by clearRunners() (would orphan the live subprocess).
1568
+ // Push the new approval-mode settings to those runners via the unified
1569
+ // duck method `updateApprovalMode` (codex thread/settings/update,
1570
+ // kimi/opencode session/set_mode) so they take effect for subsequent
1571
+ // turns without waiting for the runner to be recreated.
1572
+ const hasApprovalConfigChange = Object.keys(updates).some((k) => k === 'agents.codex' ||
1573
+ k.startsWith('agents.codex.') ||
1574
+ k === 'codex' ||
1575
+ k.startsWith('codex.') ||
1576
+ k === 'agents.kimi' ||
1577
+ k.startsWith('agents.kimi.') ||
1578
+ k === 'kimi' ||
1579
+ k.startsWith('kimi.') ||
1580
+ k === 'agents.opencode' ||
1581
+ k.startsWith('agents.opencode.') ||
1582
+ k === 'opencode' ||
1583
+ k.startsWith('opencode.'));
1584
+ if (hasApprovalConfigChange) {
1585
+ this.bridge.syncActiveApprovalModes();
1586
+ }
1483
1587
  // 2026-07-13: 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
1484
1588
  if (hasDefaultAgentChange || hasAgentConfigChange) {
1485
1589
  this.bridge.clearRunners();
@@ -1560,7 +1664,11 @@ export class CommandRouter {
1560
1664
  { cmd: 'cd', label: '/cd <path>', desc: '切换工作目录' },
1561
1665
  { cmd: 'ls', label: '/ls [dir]', desc: '列出当前目录,可指定子目录' },
1562
1666
  { cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
1563
- { cmd: 'order', label: '/order /o save|list', desc: '保存或列出常用指令(/order 默认列出)' },
1667
+ {
1668
+ cmd: 'order',
1669
+ label: '/order /o save|list',
1670
+ desc: '收藏指令 /order save,快捷别名 /order alias',
1671
+ },
1564
1672
  ];
1565
1673
  const bodyElements = [];
1566
1674
  // 按钮组 — CardKit 2.0: column_set + column + button with behaviors
@@ -1641,6 +1749,7 @@ export class CommandRouter {
1641
1749
  },
1642
1750
  };
1643
1751
  }
1752
+ /** /status 实现(public:测试直接调用,替代 as any)。 */
1644
1753
  cmdStatus(ctx) {
1645
1754
  const entry = this.sessionStore.get(ctx.userId);
1646
1755
  const cwd = entry?.cwd ?? '(未设置)';
@@ -1886,84 +1995,30 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1886
1995
  buildAutoResumeCard(cwd, session, _ctx) {
1887
1996
  const agentName = agentDisplayName(this.config.defaultAgent);
1888
1997
  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));
1998
+ const actions = this.buildResumeActionButtons({
1999
+ isActive,
2000
+ activeRunRunId,
2001
+ cwd,
2002
+ compact: this.canCompactSession(this.config.defaultAgent, cwd)
2003
+ ? { sessionId: session.sessionId, agentKind: this.config.defaultAgent }
2004
+ : undefined,
2005
+ newSession: true,
1909
2006
  });
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
- })),
2007
+ const card = buildSessionHistoryCard({
2008
+ sessionId: session.sessionId,
2009
+ cwd,
2010
+ displayTitle,
2011
+ aiTitle,
2012
+ recap,
2013
+ events: content,
2014
+ usage,
2015
+ }, {
2016
+ agentKind: this.config.defaultAgent,
2017
+ headerText: `📂 \`${cwd}\`\n已恢复最近会话: **${session.sessionId}**`,
2018
+ title: `${isActive ? '⏳ 自动恢复会话(完成中)' : '🔁 自动恢复会话'} · ${agentName}`,
2019
+ usageResult: 'success',
2020
+ actions,
1955
2021
  });
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
2022
  return { card };
1968
2023
  }
1969
2024
  /**
@@ -1977,20 +2032,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1977
2032
  const agentName = agentDisplayName(newAgent);
1978
2033
  const cwd = this.sessionStore.getCwd(ctx.userId);
1979
2034
  const sessionId = switchResult.sessionId;
1980
- // No cwd or no session → compact notice card
2035
+ // No cwd or no session → compact notice card (no new-session button: the
2036
+ // next message already starts a fresh session, a button would mislead
2037
+ // users into thinking a click is required)
1981
2038
  if (!cwd || !sessionId) {
1982
2039
  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
2040
  return {
1995
2041
  card: {
1996
2042
  schema: '2.0',
@@ -2034,79 +2080,79 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2034
2080
  agentKind: newAgent,
2035
2081
  maxEvents: AUTO_RESUME_MAX_EVENTS,
2036
2082
  });
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
2083
+ // Action buttons: stop (active) + new session
2084
+ const actions = this.buildResumeActionButtons({
2085
+ isActive,
2086
+ activeRunRunId,
2087
+ cwd,
2088
+ newSession: true,
2089
+ });
2090
+ const card = buildSessionHistoryCard({
2091
+ sessionId,
2092
+ cwd,
2093
+ displayTitle,
2094
+ aiTitle,
2095
+ recap,
2096
+ events: content,
2097
+ usage,
2098
+ }, {
2099
+ agentKind: newAgent,
2100
+ headerText: `📂 \`${cwd}\`\n已恢复会话: **${sessionId}**`,
2101
+ title: `${isActive ? '⏳ 切换 Agent(完成中)' : '🔀 切换 Agent'} · ${agentName}`,
2102
+ usageResult: 'success',
2103
+ emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
2104
+ actions,
2105
+ });
2106
+ return { card };
2107
+ }
2108
+ /**
2109
+ * Whether a session of the given agent kind can be compacted from a resume
2110
+ * card. Design doc §6.2-2: duck-typing — check if the runner has `runCompact`
2111
+ * (codex app-server, kimi acp), instead of hardcoding agentKind === 'codex'.
2112
+ */
2113
+ canCompactSession(agentKind, cwd) {
2114
+ if (!cwd)
2115
+ return false;
2116
+ return this.bridge.hasRunCompact(cwd, agentKind);
2117
+ }
2118
+ /**
2119
+ * Assemble the action buttons for a resume / session-history card.
2120
+ * Shared by buildAutoResumeCard, buildConfigSwitchCard, and cmdResume so the
2121
+ * button set (stop / compact / new-session) follows one contract instead of
2122
+ * drifting per call site.
2123
+ *
2124
+ * - stop: only when the session has an in-memory active run (isActive), bound
2125
+ * to the real activeRun.runId so bridge.interruptCurrentRun can match.
2126
+ * - compact: only when a compact-capable runner applies and the session is
2127
+ * not running (a live run would occupy the runner, so compaction would
2128
+ * fail). The caller passes `compact` only when canCompactSession holds.
2129
+ * - new-session: when requested (always shown on resume cards).
2130
+ */
2131
+ buildResumeActionButtons(opts) {
2067
2132
  const actions = [];
2068
- if (isActive) {
2133
+ if (opts.isActive) {
2069
2134
  actions.push({
2070
2135
  tag: 'button',
2071
2136
  text: { tag: 'plain_text', content: '⏹ 终止' },
2072
2137
  type: 'danger',
2073
- behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: activeRunRunId, cwd } }],
2138
+ behaviors: [
2139
+ {
2140
+ type: 'callback',
2141
+ value: { cmd: 'stop', runId: opts.activeRunRunId, cwd: opts.cwd },
2142
+ },
2143
+ ],
2074
2144
  });
2075
2145
  }
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');
2146
+ if (opts.compact && !opts.isActive) {
2147
+ actions.push(resumeCompactButton(opts.compact.sessionId, opts.compact.agentKind));
2148
+ }
2149
+ if (opts.newSession) {
2150
+ actions.push(newSessionButton());
2151
+ }
2152
+ return actions;
2108
2153
  }
2109
2154
  static LS_PAGE_SIZE = 30;
2155
+ /** /ls 实现(public:测试直接调用,替代 as unknown as)。 */
2110
2156
  cmdLs(args, ctx, offset = 0) {
2111
2157
  const cwd = this.sessionStore.getCwd(ctx.userId);
2112
2158
  if (!cwd) {
@@ -2290,78 +2336,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2290
2336
  }
2291
2337
  // Pagination bar (only shown when there are more items than PAGE_SIZE)
2292
2338
  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
- ],
2339
+ const bar = paginationBar({
2340
+ cmd: 'ls.page',
2341
+ offset,
2342
+ pageSize: CommandRouter.LS_PAGE_SIZE,
2343
+ total: totalCount,
2344
+ extra: { path: targetDir },
2345
+ label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
2337
2346
  });
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
2347
  elements.push({ tag: 'hr' });
2364
- elements.push({ tag: 'column_set', columns: pageColumns });
2348
+ elements.push(bar);
2365
2349
  }
2366
2350
  return {
2367
2351
  card: {
@@ -2451,7 +2435,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2451
2435
  const totalCount = entries.length;
2452
2436
  const totalPages = Math.max(1, Math.ceil(totalCount / WS_PAGE_SIZE));
2453
2437
  const maxOffset = Math.max(0, (totalPages - 1) * WS_PAGE_SIZE);
2454
- const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
2438
+ const safeOffset = clampInt(offset, 0, maxOffset);
2455
2439
  const currentPage = Math.floor(safeOffset / WS_PAGE_SIZE) + 1;
2456
2440
  const pageEntries = entries.slice(safeOffset, safeOffset + WS_PAGE_SIZE);
2457
2441
  const hasPagination = totalCount > WS_PAGE_SIZE;
@@ -2561,68 +2545,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2561
2545
  // Pagination bar — separate row below the list (not crowded with sort
2562
2546
  // toggle; mobile-friendly vertical layout).
2563
2547
  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
- ],
2548
+ const bar = paginationBar({
2549
+ cmd: 'ws.page',
2550
+ offset: safeOffset,
2551
+ pageSize: WS_PAGE_SIZE,
2552
+ total: totalCount,
2553
+ label: `**${currentPage}/${totalPages}**(${totalCount})`,
2554
+ prevText: '⬅',
2555
+ nextText: '➡',
2602
2556
  });
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
2557
  bodyElements.push({ tag: 'hr' });
2625
- bodyElements.push({ tag: 'column_set', columns: pageColumns });
2558
+ bodyElements.push(bar);
2626
2559
  }
2627
2560
  }
2628
2561
  const card = {
@@ -2642,6 +2575,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2642
2575
  * misleading "未找到". Reuses the same card-builder helpers (markdownDiv) as
2643
2576
  * the normal resume card; enforceCardBudget still applies at send time.
2644
2577
  */
2578
+ /**
2579
+ * /resume 核心实现(public:handleResumePage / handle / resume.use 共用,
2580
+ * 也是测试的直接 seam,避免测试用 @ts-expect-error 摸私有方法)。
2581
+ */
2645
2582
  cmdResume(args, ctx, offset = 0) {
2646
2583
  const entry = this.sessionStore.get(ctx.userId);
2647
2584
  const cwd = entry?.cwd;
@@ -2694,87 +2631,39 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2694
2631
  agentKind,
2695
2632
  maxEvents: AUTO_RESUME_MAX_EVENTS,
2696
2633
  });
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
- };
2634
+ // Action buttons: stop (active) + compact (capable runner, not running) + new session
2635
+ const actions = this.buildResumeActionButtons({
2636
+ isActive,
2637
+ activeRunRunId,
2638
+ cwd,
2639
+ compact: this.canCompactSession(agentKind, cwd)
2640
+ ? { sessionId: sessionIdArg, agentKind }
2641
+ : undefined,
2642
+ newSession: true,
2643
+ });
2644
+ const card = buildSessionHistoryCard({
2645
+ sessionId: sessionIdArg,
2646
+ cwd,
2647
+ displayTitle,
2648
+ aiTitle,
2649
+ recap,
2650
+ events: content,
2651
+ usage,
2652
+ }, {
2653
+ agentKind,
2654
+ headerText: `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`,
2655
+ title: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
2656
+ usageResult: 'success',
2657
+ emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
2658
+ actions,
2659
+ });
2771
2660
  return { card };
2772
2661
  }
2773
2662
  // `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
2774
2663
  // Use agentKind to select the appropriate sessionReader
2775
2664
  if (!cwd)
2776
2665
  return { text: '请先 /cd 设置工作目录' };
2777
- const pageSize = Math.min(Math.max(limit ?? RESUME_PAGE_SIZE, 1), RESUME_PAGE_SIZE);
2666
+ const pageSize = clampInt(limit ?? RESUME_PAGE_SIZE, 1, RESUME_PAGE_SIZE);
2778
2667
  let reader;
2779
2668
  // Use this.sessionReader when agentKind matches default, otherwise get from registry
2780
2669
  if (agentKind === this.config.defaultAgent) {
@@ -2794,7 +2683,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2794
2683
  // 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
2795
2684
  const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
2796
2685
  if (pageOffset < 0 || pageOffset > maxOffset) {
2797
- pageOffset = Math.min(Math.max(pageOffset, 0), maxOffset);
2686
+ pageOffset = clampInt(pageOffset, 0, maxOffset);
2798
2687
  pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
2799
2688
  }
2800
2689
  }
@@ -2918,80 +2807,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2918
2807
  if (total > pageSize) {
2919
2808
  const totalPages = Math.ceil(total / pageSize);
2920
2809
  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
- ],
2810
+ const bar = paginationBar({
2811
+ cmd: 'resume.page',
2812
+ offset: pageOffset,
2813
+ pageSize,
2814
+ total,
2815
+ extra: { agent: agentKind, pageSize },
2816
+ label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
2966
2817
  });
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
2818
  elements.push({ tag: 'hr' });
2994
- elements.push({ tag: 'column_set', columns: pageColumns });
2819
+ elements.push(bar);
2995
2820
  }
2996
2821
  const card = {
2997
2822
  schema: '2.0',
@@ -3219,7 +3044,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3219
3044
  // Direct inline construction (not via ConfigField) so that text (display label)
3220
3045
  // and value (agentKind for config storage) can differ — uninstalled agents get
3221
3046
  // a "⚠️ (未安装)" suffix in the label while the value stays clean.
3222
- const allAgents = listRegisteredAgents();
3047
+ // 固定展示顺序(Codex → Claude → OpenCode → Pi → Kimi),
3048
+ // 明确未安装的 agent 排到后面(getCachedAvailability === false)。
3049
+ const allAgents = sortAgentsForDisplay(listRegisteredAgents(), getCachedAvailability);
3223
3050
  const agentOptions = allAgents.map((kind) => {
3224
3051
  const available = getCachedAvailability(kind);
3225
3052
  const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
@@ -3313,11 +3140,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3313
3140
  }
3314
3141
  return { card };
3315
3142
  }
3143
+ /** /order 实现(public:测试直接调用,替代 as unknown as)。 */
3316
3144
  cmdOrder(args, _ctx, offset = 0) {
3317
3145
  const sub = args[0]?.toLowerCase();
3318
- // /order — list orders (CardKit 2.0 with pagination)
3319
- if (!sub || sub === 'list') {
3146
+ // /order — list orders + aliases (CardKit 2.0 with pagination)
3147
+ // `/order alias`(无参数)也并入列表卡片。
3148
+ if (!sub || sub === 'list' || (sub === 'alias' && !args[1])) {
3320
3149
  this.orderStore.reload();
3150
+ this.aliasStore.reload();
3321
3151
  const allOrders = this.orderStore.get();
3322
3152
  const elements = [];
3323
3153
  if (allOrders.length === 0) {
@@ -3329,7 +3159,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3329
3159
  const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
3330
3160
  // Clamp offset to last page boundary to avoid empty pages
3331
3161
  const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
3332
- const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
3162
+ const safeOffset = clampInt(offset, 0, maxOffset);
3333
3163
  const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
3334
3164
  const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
3335
3165
  const hasPagination = totalCount > ORDER_PAGE_SIZE;
@@ -3384,74 +3214,31 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3384
3214
  }
3385
3215
  // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
3386
3216
  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
- ],
3217
+ const bar = paginationBar({
3218
+ cmd: 'order.page',
3219
+ offset: safeOffset,
3220
+ pageSize: ORDER_PAGE_SIZE,
3221
+ total: totalCount,
3222
+ label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
3428
3223
  });
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
3224
  elements.push({ tag: 'hr' });
3454
- elements.push({ tag: 'column_set', columns: pageColumns });
3225
+ elements.push(bar);
3226
+ }
3227
+ }
3228
+ // 别名区:并入 /order 卡片(`/order alias` 列出全部)。
3229
+ const aliases = this.aliasStore.list();
3230
+ if (aliases.length > 0) {
3231
+ elements.push({ tag: 'hr' });
3232
+ elements.push({
3233
+ tag: 'div',
3234
+ text: { tag: 'lark_md', content: '**⚡ 别名**(输入 $name 触发)' },
3235
+ });
3236
+ for (const alias of aliases) {
3237
+ const text = alias.text.length > 100 ? alias.text.slice(0, 97) + '...' : alias.text;
3238
+ elements.push({
3239
+ tag: 'div',
3240
+ text: { tag: 'lark_md', content: `$${alias.name} → ${text}` },
3241
+ });
3455
3242
  }
3456
3243
  }
3457
3244
  return {
@@ -3463,6 +3250,47 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3463
3250
  },
3464
3251
  };
3465
3252
  }
3253
+ // /order alias <name> <text> — 注册;/order alias <name> — 查看;/order alias remove <name> — 删除
3254
+ if (sub === 'alias') {
3255
+ const name = args[1];
3256
+ if (name === 'remove') {
3257
+ const target = args[2];
3258
+ if (!target) {
3259
+ return { text: '用法: /order alias remove <name>' };
3260
+ }
3261
+ // remove 是保留子命令,不能作为别名名:后面跟多词文本说明用户想注册
3262
+ // 名为 remove 的别名,明确报错而不是静默删除已存在的 target(P2 review)。
3263
+ if (args.length > 3) {
3264
+ return { text: '`remove` 是保留子命令,不能作为别名名,请换一个名称' };
3265
+ }
3266
+ const entry = this.aliasStore.get(target);
3267
+ if (!entry) {
3268
+ return { text: `别名 $${target} 不存在` };
3269
+ }
3270
+ this.aliasStore.remove(target);
3271
+ // 回显原内容:单词场景无法区分「注册 remove」与「删除」,误删后
3272
+ // 用户可直接按回显内容重新注册,消除静默破坏(P3 review)。
3273
+ const preview = entry.text.length > 50 ? entry.text.slice(0, 50) + '...' : entry.text;
3274
+ return { text: `✅ 已删除别名 $${target}(原内容:${preview})` };
3275
+ }
3276
+ const text = args.slice(2).join(' ');
3277
+ if (!text) {
3278
+ const entry = name ? this.aliasStore.get(name) : undefined;
3279
+ if (!entry) {
3280
+ return { text: name ? `别名 $${name} 不存在` : '用法: /order alias <name> <text>' };
3281
+ }
3282
+ return { text: `$${entry.name} → ${entry.text}` };
3283
+ }
3284
+ try {
3285
+ const existed = this.aliasStore.has(name);
3286
+ this.aliasStore.set(name, text);
3287
+ const preview = text.length > 50 ? text.slice(0, 50) + '...' : text;
3288
+ return { text: `${existed ? '✅ 已更新别名' : '✅ 已保存别名'} $${name} → ${preview}` };
3289
+ }
3290
+ catch (err) {
3291
+ return { text: `保存失败: ${err.message}` };
3292
+ }
3293
+ }
3466
3294
  // /order save <text>
3467
3295
  if (sub === 'save') {
3468
3296
  const text = args.slice(1).join(' ');
@@ -3477,10 +3305,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3477
3305
  return { text: `保存失败: ${err.message}` };
3478
3306
  }
3479
3307
  }
3480
- return { text: '用法: /order [list|save <text>]' };
3308
+ return { text: '用法: /order [list|save <text>|alias <name> <text>|alias remove <name>]' };
3481
3309
  }
3482
3310
  }
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
3311
  //# sourceMappingURL=index.js.map