lark-remote 0.5.10 → 0.5.13

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 (126) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/binder.js +7 -1
  5. package/dist/bridge/approval-coordinator.d.ts +1 -0
  6. package/dist/bridge/approval-coordinator.js +12 -24
  7. package/dist/bridge/index.d.ts +7 -0
  8. package/dist/bridge/index.js +89 -230
  9. package/dist/bridge/run-approval.d.ts +15 -0
  10. package/dist/bridge/run-approval.js +41 -0
  11. package/dist/bridge/run-usage.d.ts +46 -0
  12. package/dist/bridge/run-usage.js +96 -0
  13. package/dist/bridge/session-writeback.d.ts +18 -0
  14. package/dist/bridge/session-writeback.js +36 -0
  15. package/dist/card/approval-render.d.ts +14 -0
  16. package/dist/card/approval-render.js +155 -29
  17. package/dist/card/bash-card-session.js +10 -5
  18. package/dist/card/bash-renderer.js +11 -23
  19. package/dist/card/card-budget.js +24 -4
  20. package/dist/card/card-session.d.ts +2 -0
  21. package/dist/card/card-session.js +20 -22
  22. package/dist/card/card-shared.d.ts +4 -4
  23. package/dist/card/card-shared.js +3 -9
  24. package/dist/card/collapsible.js +3 -2
  25. package/dist/card/final-answer-overflow.d.ts +7 -0
  26. package/dist/card/final-answer-overflow.js +46 -0
  27. package/dist/card/run-card-session.js +2 -7
  28. package/dist/card/run-renderer.d.ts +14 -0
  29. package/dist/card/run-renderer.js +150 -10
  30. package/dist/card/run-state.d.ts +12 -1
  31. package/dist/card/run-state.js +73 -34
  32. package/dist/common/text-width.js +5 -0
  33. package/dist/common/tool-summary.js +4 -2
  34. package/dist/config/codex-config.js +5 -3
  35. package/dist/config/index.d.ts +4 -2
  36. package/dist/config/index.js +19 -5
  37. package/dist/inbound/turn-assembler.js +6 -0
  38. package/dist/index.js +15 -391
  39. package/dist/instance-lock.d.ts +9 -1
  40. package/dist/instance-lock.js +42 -7
  41. package/dist/message-handlers.d.ts +26 -0
  42. package/dist/message-handlers.js +275 -0
  43. package/dist/platform/identity.js +4 -3
  44. package/dist/platform/path.js +3 -1
  45. package/dist/platform/terminator-win32.d.ts +13 -1
  46. package/dist/platform/terminator-win32.js +4 -14
  47. package/dist/router/card-action-payload.d.ts +1 -1
  48. package/dist/router/card-action-payload.js +1 -1
  49. package/dist/router/commands/ls.d.ts +9 -0
  50. package/dist/router/commands/ls.js +243 -0
  51. package/dist/router/commands/order.d.ts +12 -0
  52. package/dist/router/commands/order.js +178 -0
  53. package/dist/router/commands/resume.d.ts +47 -0
  54. package/dist/router/commands/resume.js +246 -0
  55. package/dist/router/commands/ws.d.ts +15 -0
  56. package/dist/router/commands/ws.js +189 -0
  57. package/dist/router/config/claude.js +3 -3
  58. package/dist/router/config/codex.js +2 -6
  59. package/dist/router/config/common/turn-idle.d.ts +2 -0
  60. package/dist/router/config/common/turn-idle.js +9 -0
  61. package/dist/router/config/kimi.js +2 -6
  62. package/dist/router/config/opencode.js +2 -6
  63. package/dist/router/index.d.ts +14 -22
  64. package/dist/router/index.js +93 -1133
  65. package/dist/router/paging.d.ts +13 -0
  66. package/dist/router/paging.js +20 -0
  67. package/dist/router/path-validation.d.ts +27 -0
  68. package/dist/router/path-validation.js +45 -0
  69. package/dist/runner/claude/runner.d.ts +3 -4
  70. package/dist/runner/claude/runner.js +4 -5
  71. package/dist/runner/claude/session.js +1 -1
  72. package/dist/runner/codex/app-server/runner.js +12 -0
  73. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  74. package/dist/runner/codex/app-server/translator.js +65 -9
  75. package/dist/runner/common/acp/base-translator.d.ts +15 -8
  76. package/dist/runner/common/acp/base-translator.js +38 -7
  77. package/dist/runner/common/jsonl-stream.js +12 -1
  78. package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
  79. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  80. package/dist/runner/common/jsonrpc/transport.js +7 -1
  81. package/dist/runner/common/spawning-runner.js +10 -4
  82. package/dist/runner/common/turn-started.d.ts +2 -0
  83. package/dist/runner/common/turn-started.js +10 -0
  84. package/dist/runner/dsh/approval.js +16 -3
  85. package/dist/runner/dsh/auth.js +2 -3
  86. package/dist/runner/dsh/client.d.ts +32 -7
  87. package/dist/runner/dsh/client.js +115 -149
  88. package/dist/runner/dsh/daemon.d.ts +0 -2
  89. package/dist/runner/dsh/daemon.js +1 -6
  90. package/dist/runner/dsh/errors.d.ts +5 -0
  91. package/dist/runner/dsh/errors.js +11 -0
  92. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  93. package/dist/runner/dsh/exit-cancel.js +63 -24
  94. package/dist/runner/dsh/home.d.ts +2 -0
  95. package/dist/runner/dsh/home.js +8 -0
  96. package/dist/runner/dsh/inflight.d.ts +0 -2
  97. package/dist/runner/dsh/inflight.js +1 -1
  98. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  99. package/dist/runner/dsh/mux-stream.js +157 -0
  100. package/dist/runner/dsh/runner.d.ts +7 -0
  101. package/dist/runner/dsh/runner.js +42 -5
  102. package/dist/runner/dsh/translator.d.ts +0 -10
  103. package/dist/runner/dsh/translator.js +1 -1
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.js +42 -5
  106. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  107. package/dist/runner/kimi/acp/translator.js +11 -12
  108. package/dist/runner/opencode/acp/translator.js +1 -12
  109. package/dist/runner/pi/rpc/runner.js +33 -5
  110. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  111. package/dist/runner/pi/rpc/translator.js +2 -7
  112. package/dist/runner/registry.d.ts +0 -19
  113. package/dist/runner/registry.js +0 -32
  114. package/dist/runner/resolve-agent-choices.js +5 -1
  115. package/dist/runner/sync-agent-choices.js +4 -1
  116. package/dist/runner/types.d.ts +17 -1
  117. package/dist/session/claude/session-index.js +13 -4
  118. package/dist/session/codex/rollout-reader.js +51 -26
  119. package/dist/session/common/usage-accumulator.d.ts +10 -1
  120. package/dist/session/common/usage-accumulator.js +10 -2
  121. package/dist/session/dsh/sessions.js +29 -9
  122. package/dist/session/kimi/sessions.js +11 -7
  123. package/dist/session/opencode/sessions.js +15 -7
  124. package/dist/session/pi/sessions.js +3 -2
  125. package/dist/session/session-store.js +4 -3
  126. package/package.json +1 -1
@@ -1,4 +1,3 @@
1
- import fs from 'node:fs';
2
1
  import os from 'node:os';
3
2
  import path from 'node:path';
4
3
  import YAML from 'yaml';
@@ -14,7 +13,7 @@ import { getLogger } from '../logger/index.js';
14
13
  import { stripPlaceholders } from '../inbound/placeholder.js';
15
14
  import { clampInt } from '../common/clamp.js';
16
15
  import { activeRunUsage } from './usage-format.js';
17
- import { markdownDiv, buildSessionHistoryCard, paginationBar, searchBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
16
+ import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
18
17
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
19
18
  import { cardButton, newSessionButton, resumeCompactButton, agentDisplayName, } from '../card/card-shared.js';
20
19
  import { displayName } from '../platform/path.js';
@@ -24,36 +23,16 @@ import { checkLatestVersion as defaultCheckLatestVersion, compareVersions as def
24
23
  import { getConfigBuilder, listRegisteredAgents, sortAgentsForDisplay } from './config/index.js';
25
24
  import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
26
25
  import { buildConfigCardFromTabs } from './config/common/render.js';
26
+ import { isDirectorySafe, resolveDirectory, statPath } from './path-validation.js';
27
+ import { pageInfo } from './paging.js';
28
+ import { buildLsCommand, LS_PAGE_SIZE } from './commands/ls.js';
29
+ import { buildWsCommand, WS_PAGE_SIZE } from './commands/ws.js';
30
+ import { buildOrderCommand, ORDER_PAGE_SIZE } from './commands/order.js';
31
+ import { buildResumeCommand, AUTO_RESUME_MAX_EVENTS, RESUME_PAGE_SIZE } from './commands/resume.js';
27
32
  /** Max events to read for auto-resume and /resume cards.
28
33
  * Limits events at the read stage so enforceCardBudget rarely needs to truncate.
29
34
  * Chosen to be under the 28KB card budget for typical events (~2KB each). */
30
- const AUTO_RESUME_MAX_EVENTS = 5;
31
- /** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
32
- const RESUME_PAGE_SIZE = 5;
33
35
  /** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
34
- /**
35
- * 分页算术(原先 5 处内联且 clamp 语义漂移:/ls 不 clamp、/active clamp 到
36
- * totalCount-1 会把过期 offset 泄漏成残页)。统一 clamp 到最后一页边界。
37
- */
38
- function pageInfo(totalCount, offset, pageSize) {
39
- const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
40
- const maxOffset = Math.max(0, (totalPages - 1) * pageSize);
41
- const safeOffset = clampInt(offset, 0, maxOffset);
42
- const currentPage = Math.floor(safeOffset / pageSize) + 1;
43
- return { totalPages, safeOffset, currentPage };
44
- }
45
- /** 在 pageInfo 之上切出当前页条目。 */
46
- function pageSlice(items, offset, pageSize) {
47
- const { totalPages, safeOffset, currentPage } = pageInfo(items.length, offset, pageSize);
48
- return {
49
- totalCount: items.length,
50
- totalPages,
51
- safeOffset,
52
- currentPage,
53
- pageItems: items.slice(safeOffset, safeOffset + pageSize),
54
- hasPagination: items.length > pageSize,
55
- };
56
- }
57
36
  const ACTIVE_PAGE_SIZE = 20;
58
37
  /**
59
38
  * 飞书会把极短间隔连发的多条消息合并成一条换行分隔的消息:用户侧看是两条消息,
@@ -75,15 +54,6 @@ function findMergedCommandTail(message) {
75
54
  return hasSlashLine ? tail : null;
76
55
  }
77
56
  /** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
78
- function formatBytes(bytes) {
79
- if (bytes < 1024)
80
- return `${bytes}B`;
81
- if (bytes < 1024 * 1024)
82
- return `${(bytes / 1024).toFixed(1)}KB`;
83
- if (bytes < 1024 * 1024 * 1024)
84
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
85
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
86
- }
87
57
  /**
88
58
  * /order 列表页大小;指令超过此数量时显示分页导航栏。
89
59
  * 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
@@ -93,13 +63,11 @@ function formatBytes(bytes) {
93
63
  * 从 15 降为 8:3*8 - 1 + 2 = 25 个元素,仍在余量内,同时保证手机窄屏
94
64
  * 每条指令的操作控件(执行/别名/删除)不被挤压。
95
65
  */
96
- const ORDER_PAGE_SIZE = 8;
97
66
  /**
98
67
  * /ws 列表页大小。从 15 降为 5:头部 2 + 5 行 × 3(div + column_set + hr)+
99
68
  * 底栏排序 1 + 分页栏 2 ≈ 20 个 body 元素,远低于单卡 60 元素上限(ErrCode 11310),
100
69
  * 留足余量。
101
70
  */
102
- const WS_PAGE_SIZE = 5;
103
71
  /**
104
72
  * 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
105
73
  * 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
@@ -126,25 +94,17 @@ function normalizePageArgs(value, maxPageSize) {
126
94
  return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
127
95
  }
128
96
  /** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
129
- const RESUME_CONTENT_PREFETCH = 5;
130
97
  /**
131
98
  * Reader summary 占位符(trim 后匹配)。各 reader 在"无 user 消息"时返回的
132
99
  * 兜底文案不同(claude/pi `(无摘要)`、codex `(no user message)`、kimi
133
100
  * `New Session`、空串),都不是真实输入,不得渲染成行标题。
134
101
  */
135
- const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
136
- /** 列表卡「最近输入 / 最后输出」单行预览的长度上限(中文按字符,手机约两行)。 */
137
- const RESUME_PREVIEW_MAX = 60;
138
102
  /**
139
103
  * 列表卡单行预览:压平空白、去掉反引号,再按字符截断。
140
104
  *
141
105
  * 为什么去反引号:agent 的输出里 `code` 很常见,截断点落在一对反引号中间时
142
106
  * 后半张卡片的 markdown 会被当成代码块渲染(lark_md 不闭合就不回退)。
143
107
  */
144
- function sessionPreviewLine(text, max) {
145
- const flat = text.replace(/`/g, '').replace(/\s+/g, ' ').trim();
146
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
147
- }
148
108
  /**
149
109
  * Card-action commands that must run with enqueue({ immediate: true }).
150
110
  * These bypass the serial queue and execute immediately.
@@ -319,6 +279,10 @@ const IMMEDIATE_ACTION_CMDS = new Set([
319
279
  'queue.diagnose',
320
280
  'queue.edit',
321
281
  'queue.input',
282
+ // 终态卡「停止后台任务」:纯远程 job/kill 请求,不产生 agent 工作(§9.19
283
+ // 控制操作免排队)。排进串行队列会等当前 run 结束才杀任务——stop 类按钮
284
+ // 排队正是 §9.6 要防的形态,且排队期间还占 lane 多出一张排队卡。
285
+ 'job.kill',
322
286
  'order.delete',
323
287
  'order.page',
324
288
  'order.aliasEdit',
@@ -973,6 +937,11 @@ export class CommandRouter {
973
937
  const task = this.bridge.getQueuedTask(cwd, messageId);
974
938
  const queueInfo = this.bridge.getQueueInfo(cwd);
975
939
  const activeRuns = this.bridge.getAllActiveRuns();
940
+ // 「排在当前任务之前」按诊断目标自己的位置算(与 sendQueueStatusCard 同法)。
941
+ // getQueueInfo.tasksAhead 是队尾视角(len-1),诊断目标不是队尾时数字误导。
942
+ const queuedTasks = this.bridge.getQueuedTasks(cwd);
943
+ const targetIdx = queuedTasks.findIndex((t) => t.messageId === messageId);
944
+ const tasksAhead = targetIdx >= 0 ? targetIdx : queueInfo.tasksAhead;
976
945
  // Build diagnostic info
977
946
  const now = new Date().toLocaleString('zh-CN');
978
947
  const pid = process.pid;
@@ -1012,7 +981,7 @@ export class CommandRouter {
1012
981
  tag: 'div',
1013
982
  text: {
1014
983
  tag: 'lark_md',
1015
- content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${queueInfo.tasksAhead}`,
984
+ content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${tasksAhead}`,
1016
985
  },
1017
986
  },
1018
987
  { tag: 'hr' },
@@ -1203,26 +1172,17 @@ export class CommandRouter {
1203
1172
  const resolvedTarget = path.resolve(targetPath);
1204
1173
  // 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
1205
1174
  // 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
1206
- if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
1207
- await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1208
- return;
1209
- }
1210
- // Canonicalize via realpath; if the target vanished between stat and
1211
- // realpath (TOCTOU), reject gracefully instead of throwing.
1212
- let canonical;
1213
- try {
1214
- canonical = fs.realpathSync(resolvedTarget);
1215
- }
1216
- catch {
1175
+ const resolution = resolveDirectory(resolvedTarget);
1176
+ if (!resolution.ok) {
1217
1177
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1218
1178
  return;
1219
1179
  }
1220
1180
  // Set cwd + auto-resume + user notification (shared with /cd and /ws use)
1221
- const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
1181
+ const notifyResult = this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
1222
1182
  // Card channel: refresh ls card in-place (existing behavior preserved)。
1223
1183
  // value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
1224
1184
  // 「返回」仍回到用户最初 /ls 的那个目录。
1225
- const card = this.cmdLs([canonical], ctx, 0, value.root);
1185
+ const card = this.cmdLs([resolution.canonical], ctx, 0, value.root);
1226
1186
  await this.bridge.updateCardInPlace(card.card, ctx);
1227
1187
  // Message channel: send switch confirmation / auto-resume card to chat
1228
1188
  await this.bridge.sendResult(notifyResult, ctx);
@@ -1233,21 +1193,11 @@ export class CommandRouter {
1233
1193
  async handleLsRefresh(value, ctx) {
1234
1194
  const targetPath = value.path;
1235
1195
  const offset = value.offset ?? 0;
1236
- // TOCTOU guard: existsSync → statSync can race (target
1237
- // deleted in between), throwing into enqueueImmediate's catch which only
1238
- // logs — violating the "card button clicks must give visible feedback"
1239
- // red line. Treat a vanished/non-dir path as "use current cwd" instead of
1240
- // crashing the refresh.
1241
- let card;
1242
- try {
1243
- card =
1244
- targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
1245
- ? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
1246
- : this.cmdLs([], ctx, offset, value.root, value.q);
1247
- }
1248
- catch {
1249
- card = this.cmdLs([], ctx, offset, value.root, value.q);
1250
- }
1196
+ // Treat a vanished/non-dir path as "use current cwd" instead of crashing
1197
+ // the refresh. inspectPath closes the former existsSync→statSync race.
1198
+ const card = targetPath && isDirectorySafe(targetPath)
1199
+ ? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
1200
+ : this.cmdLs([], ctx, offset, value.root, value.q);
1251
1201
  await this.bridge.updateCardInPlace(card.card, ctx);
1252
1202
  }
1253
1203
  /**
@@ -1258,22 +1208,12 @@ export class CommandRouter {
1258
1208
  if (!targetPath) {
1259
1209
  return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
1260
1210
  }
1261
- const paging = this.pagingOffset(value, CommandRouter.LS_PAGE_SIZE);
1211
+ const paging = this.pagingOffset(value, LS_PAGE_SIZE);
1262
1212
  if ('error' in paging) {
1263
1213
  return { toast: { type: 'error', content: paging.error } };
1264
1214
  }
1265
1215
  const resolvedTarget = path.resolve(targetPath);
1266
- // TOCTOU guard: existsSync → statSync can race; without the
1267
- // try/catch the throw lands in enqueueImmediate's silent .catch and the
1268
- // paginated card never updates (no visible feedback for the click).
1269
- let isDir;
1270
- try {
1271
- isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
1272
- }
1273
- catch {
1274
- isDir = false;
1275
- }
1276
- if (!isDir) {
1216
+ if (!isDirectorySafe(resolvedTarget)) {
1277
1217
  return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
1278
1218
  }
1279
1219
  const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root, value.q);
@@ -1295,14 +1235,7 @@ export class CommandRouter {
1295
1235
  }
1296
1236
  const q = searchQueryFrom(value);
1297
1237
  const resolvedTarget = path.resolve(targetPath);
1298
- let isDir;
1299
- try {
1300
- isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
1301
- }
1302
- catch {
1303
- isDir = false;
1304
- }
1305
- if (!isDir) {
1238
+ if (!isDirectorySafe(resolvedTarget)) {
1306
1239
  return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
1307
1240
  }
1308
1241
  const card = this.cmdLs([resolvedTarget], ctx, 0, value.root, q);
@@ -1370,7 +1303,7 @@ export class CommandRouter {
1370
1303
  return;
1371
1304
  }
1372
1305
  const resolvedTarget = path.resolve(targetPath);
1373
- if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
1306
+ if (!isDirectorySafe(resolvedTarget)) {
1374
1307
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1375
1308
  return;
1376
1309
  }
@@ -1417,8 +1350,12 @@ export class CommandRouter {
1417
1350
  async handleJobKill(value, ctx) {
1418
1351
  const runId = typeof value.runId === 'string' ? value.runId : '';
1419
1352
  const jobId = typeof value.jobId === 'string' ? value.jobId : '';
1420
- if (!runId || !jobId)
1353
+ // miss path 必回复(本文件的设计约束):旧版/异常卡片 payload 缺参时
1354
+ // 静默 return 会让点击毫无反应,用户无从判断。
1355
+ if (!runId || !jobId) {
1356
+ await this.bridge.sendResult({ text: CARD_PAYLOAD_MISSING }, ctx);
1421
1357
  return;
1358
+ }
1422
1359
  if (await this.bridge.killBackgroundJob(runId, jobId, ctx))
1423
1360
  return;
1424
1361
  await this.bridge.sendResult({ text: '停止失败:任务可能已自行结束,或记录已回收' }, ctx);
@@ -1743,12 +1680,14 @@ export class CommandRouter {
1743
1680
  }
1744
1681
  // Refresh the /ws list card in place so "recent" sort is immediately visible
1745
1682
  await this.refreshListCard('ws', value, ctx);
1746
- // Toast feedback (suppress text when a persistent message was already sent)
1683
+ // Toast feedback (suppress text when a persistent message was already sent).
1684
+ // 兜底文案必须用 warning 型:ws.use 不在 DIRECT_RETURN_CMDS,toast 经
1685
+ // enqueueImmediate fire-and-forget,而 forwardActionFeedback 只转发
1686
+ // error/warning 型——success 型兜底会被双重吞掉,点击毫无反馈。
1747
1687
  return {
1748
- toast: {
1749
- type: 'success',
1750
- content: fallbackToast ?? '',
1751
- },
1688
+ toast: fallbackToast
1689
+ ? { type: 'warning', content: fallbackToast }
1690
+ : { type: 'success', content: '' },
1752
1691
  };
1753
1692
  }
1754
1693
  /**
@@ -1827,16 +1766,13 @@ export class CommandRouter {
1827
1766
  * `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
1828
1767
  */
1829
1768
  async deliverFileToFeishu(resolved, ctx) {
1830
- if (!fs.existsSync(resolved)) {
1831
- return { text: `文件不存在: ${resolved}` };
1832
- }
1833
- let stat;
1834
- try {
1835
- stat = fs.statSync(resolved);
1836
- }
1837
- catch {
1769
+ const resolution = statPath(resolved);
1770
+ if (!resolution.ok) {
1771
+ if (resolution.reason === 'missing')
1772
+ return { text: `文件不存在: ${resolved}` };
1838
1773
  return { text: `无法读取: ${resolved}` };
1839
1774
  }
1775
+ const { stat } = resolution;
1840
1776
  if (stat.isDirectory()) {
1841
1777
  return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
1842
1778
  }
@@ -2600,30 +2536,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2600
2536
  const currentCwd = this.sessionStore.getCwd(ctx.userId) ?? process.cwd();
2601
2537
  const expanded = target.startsWith('~') ? path.join(os.homedir(), target.slice(1)) : target;
2602
2538
  const resolved = path.resolve(currentCwd, expanded);
2603
- if (!fs.existsSync(resolved)) {
2604
- return { text: `路径不存在: ${resolved}` };
2605
- }
2606
- const stat = fs.statSync(resolved);
2607
- if (!stat.isDirectory()) {
2608
- return { text: `不是目录: ${resolved}` };
2609
- }
2610
- // Canonicalize via realpath so it matches the symlink-resolved cwd that
2611
- // Claude writes into JSONL (e.g. `/tmp` → `/private/tmp` on macOS).
2612
- // Without this, a hand-typed unresolved path never matches any JSONL's
2613
- // cwd field and the user's `/cd /tmp/foo` never finds their sessions.
2614
- // TOCTOU: the directory may vanish between stat and realpath.
2615
- // Reject gracefully instead of letting realpathSync throw into the queue
2616
- // task's .catch (which only logs — the user gets no feedback). Mirrors
2617
- // handleLsSwitch's realpath guard so /cd and ls.switch behave identically.
2618
- let canonical;
2619
- try {
2620
- canonical = fs.realpathSync(resolved);
2621
- }
2622
- catch {
2539
+ const resolution = resolveDirectory(resolved);
2540
+ if (!resolution.ok) {
2541
+ if (resolution.reason === 'missing')
2542
+ return { text: `路径不存在: ${resolved}` };
2543
+ if (resolution.reason === 'not_directory')
2544
+ return { text: `不是目录: ${resolved}` };
2623
2545
  return { text: `路径无效: ${resolved}` };
2624
2546
  }
2625
2547
  // Switch cwd with auto-resume and user feedback (shared with /ws use and ls.switch)
2626
- return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
2548
+ return this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
2627
2549
  }
2628
2550
  readSessionDisplayState(sessionId, cwd, opts) {
2629
2551
  // Use the appropriate session reader based on agentKind
@@ -2834,7 +2756,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2834
2756
  }
2835
2757
  return actions;
2836
2758
  }
2837
- static LS_PAGE_SIZE = 30;
2838
2759
  /**
2839
2760
  * `/ls` 实现(public:测试直接调用,替代 as unknown as)。
2840
2761
  *
@@ -2847,246 +2768,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2847
2768
  * 省略/空 = 无筛选。
2848
2769
  */
2849
2770
  cmdLs(args, ctx, offset = 0, rootDir, q) {
2850
- const cwd = this.sessionStore.getCwd(ctx.userId);
2851
- if (!cwd) {
2852
- return { text: '请先使用 /cd <path> 设置工作目录' };
2853
- }
2854
- const filter = q?.trim() || undefined;
2855
- // args can be:
2856
- // - [] : list current cwd
2857
- // - [dirName] : list subdirectory if it exists
2858
- let targetDir = cwd;
2859
- if (args[0]) {
2860
- // Expand tilde to home directory (path.resolve does not handle ~)
2861
- if (args[0].startsWith('~')) {
2862
- args[0] = path.join(os.homedir(), args[0].slice(1));
2863
- }
2864
- // path.resolve (not path.join) so absolute args[0] is honored as-is
2865
- // instead of being concatenated onto cwd (which broke /ls card nav).
2866
- const potentialDir = path.resolve(cwd, args[0]);
2867
- if (!fs.existsSync(potentialDir)) {
2868
- return { text: `ls: ${args[0]}: No such file or directory` };
2869
- }
2870
- let isDir;
2871
- try {
2872
- isDir = fs.statSync(potentialDir).isDirectory();
2873
- }
2874
- catch {
2875
- // TOCTOU:existsSync 与 statSync 之间目标被删
2876
- return { text: `ls: ${args[0]}: No such file or directory` };
2877
- }
2878
- if (!isDir) {
2879
- // 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
2880
- // 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
2881
- return this.buildFileCard(potentialDir);
2882
- }
2883
- targetDir = potentialDir;
2884
- }
2885
- // 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
2886
- // 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
2887
- // 列出的目录,因此「返回」永远不会把用户丢回工作目录。
2888
- const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
2889
- try {
2890
- const entries = fs.readdirSync(targetDir, { withFileTypes: true });
2891
- // 逐条目分类。`Dirent.isFile()/isDirectory()` 对符号链接恒 false(旧实现
2892
- // 因此完全不列链接);必须 stat 解析目标。单条目 stat 失败(悬空链接、
2893
- // readdir 与 stat 之间被删、无权限)只让该条目降级为「大小未知」,不能
2894
- // 让整次列目录失败——一个坏邻居毁掉整张卡是旧实现的第二宗罪。
2895
- const dirs = [];
2896
- const files = [];
2897
- for (const entry of entries) {
2898
- let isDir = entry.isDirectory();
2899
- let size;
2900
- if (!isDir) {
2901
- try {
2902
- const st = fs.statSync(path.join(targetDir, entry.name));
2903
- isDir = st.isDirectory();
2904
- if (!isDir)
2905
- size = st.size;
2906
- }
2907
- catch {
2908
- // 目标拿不到:按文件列出,size 留空
2909
- }
2910
- }
2911
- if (isDir)
2912
- dirs.push(entry.name);
2913
- else
2914
- files.push({ name: entry.name, size });
2915
- }
2916
- dirs.sort((a, b) => a.localeCompare(b));
2917
- files.sort((a, b) => a.name.localeCompare(b.name));
2918
- // Merge dirs and files for pagination, preserving type info
2919
- const allItems = [
2920
- ...dirs.map((name) => ({ name, isDir: true })),
2921
- ...files.map((f) => ({ name: f.name, isDir: false, size: f.size })),
2922
- ];
2923
- // 关键词筛选:只匹配当前层条目名(不递归子目录)。大小写不敏感的子串包含
2924
- // ——禁止 new RegExp(q)(用户搜 `foo(` 会直接抛)。插在排序之后、分页之前。
2925
- const ql = filter?.toLowerCase();
2926
- const visibleItems = ql
2927
- ? allItems.filter((i) => i.name.toLowerCase().includes(ql))
2928
- : allItems;
2929
- const visibleDirCount = visibleItems.reduce((n, i) => n + (i.isDir ? 1 : 0), 0);
2930
- const visibleFileCount = visibleItems.length - visibleDirCount;
2931
- // Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
2932
- const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(visibleItems, offset, CommandRouter.LS_PAGE_SIZE);
2933
- // Check if we need to show parent directory button
2934
- const parentDir = path.dirname(targetDir);
2935
- const hasParent = parentDir !== targetDir;
2936
- // 「切换」相对 cwd(把工作目录切到当前浏览目录)
2937
- const isSubdir = targetDir !== cwd;
2938
- // 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
2939
- const canReturn = targetDir !== browseRoot;
2940
- // Build header with parent button
2941
- const headerElements = [];
2942
- if (hasParent) {
2943
- headerElements.push(cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: browseRoot }, { type: 'default', size: 'small' }));
2944
- }
2945
- // Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
2946
- if (canReturn) {
2947
- headerElements.push(cardButton('返回', { cmd: 'ls.browse', path: browseRoot, root: browseRoot }, { type: 'default', size: 'small' }));
2948
- }
2949
- headerElements.push(cardButton('刷新', {
2950
- cmd: 'ls.refresh',
2951
- path: targetDir,
2952
- offset: safeOffset,
2953
- root: browseRoot,
2954
- ...(filter ? { q: filter } : {}),
2955
- }, { type: 'default', size: 'small' }));
2956
- // Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
2957
- if (isSubdir) {
2958
- headerElements.push(
2959
- // 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
2960
- // 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
2961
- cardButton('切换', { cmd: 'ls.switch', path: targetDir, root: browseRoot }, { type: 'primary', size: 'small' }));
2962
- }
2963
- // Helper to create directory button - use targetDir
2964
- const dirButton = (name) => cardButton(`📁 ${name}`, { cmd: 'ls.browse', path: path.join(targetDir, name), root: browseRoot }, { type: 'default' });
2965
- // Helper to create file button with size - use targetDir
2966
- const fileButton = (name, size) => {
2967
- const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
2968
- return cardButton(`📄 ${name}${sizeStr}`, { cmd: 'ls.file', path: path.join(targetDir, name) }, { type: 'default', size: 'small' });
2969
- };
2970
- // Build each section's elements (no tabs - use section headers)
2971
- // Note: div+elements is not supported in CardKit 2.0, use column_set instead
2972
- const elements = [];
2973
- // Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
2974
- // 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。筛选态一律用
2975
- // 过滤后的计数,否则标题说 12 项、列表只有 3 项,卡片自相矛盾。
2976
- const status = `\n共 ${visibleDirCount} 目录, ${visibleFileCount} 文件`;
2977
- // Header info + navigation buttons - show targetDir in header
2978
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
2979
- elements.push({
2980
- tag: 'column_set',
2981
- columns: headerElements.map((e) => ({ tag: 'column', width: 'auto', elements: [e] })),
2982
- });
2983
- elements.push({ tag: 'hr' });
2984
- // 搜索行:头部导航按钮之下、内容区之上(主导航在上、过滤紧随其后、内容区
2985
- // 在下)。path/root 必须进 callback value,否则提交后 handler 不知道刷哪。
2986
- elements.push(searchBar({
2987
- cmd: 'ls.filter',
2988
- placeholder: '搜索本层目录/文件名,输完回车',
2989
- currentQuery: filter,
2990
- extra: { path: targetDir, root: browseRoot },
2991
- }));
2992
- if (filter) {
2993
- elements.push({
2994
- tag: 'div',
2995
- text: {
2996
- tag: 'lark_md',
2997
- content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 项(点「清除筛选」取消)`,
2998
- },
2999
- });
3000
- }
3001
- // Section 1 + 2: Directories / Files (show only items on current page)
3002
- const pageDirs = pageItems.filter((i) => i.isDir);
3003
- const pageFiles = pageItems.filter((i) => !i.isDir);
3004
- if (filter && visibleItems.length === 0) {
3005
- // 筛选零命中:两个区标题都不渲染(两行空区标题只是噪音),搜索行仍在,
3006
- // 用户可直接改词再回车,或点「清除筛选」。
3007
- elements.push({
3008
- tag: 'div',
3009
- text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
3010
- });
3011
- }
3012
- else {
3013
- elements.push({
3014
- tag: 'div',
3015
- text: { tag: 'lark_md', content: `**📂 目录 (${visibleDirCount})**` },
3016
- });
3017
- if (pageDirs.length > 0) {
3018
- for (const d of pageDirs) {
3019
- elements.push({
3020
- tag: 'column_set',
3021
- flex_mode: 'none',
3022
- columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
3023
- });
3024
- }
3025
- }
3026
- else if (visibleDirCount === 0) {
3027
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
3028
- }
3029
- else {
3030
- elements.push({
3031
- tag: 'div',
3032
- text: { tag: 'lark_md', content: `(第 ${currentPage} 页无目录)` },
3033
- });
3034
- }
3035
- elements.push({ tag: 'hr' });
3036
- // Section 2: Files
3037
- elements.push({
3038
- tag: 'div',
3039
- text: { tag: 'lark_md', content: `**📄 文件 (${visibleFileCount})**` },
3040
- });
3041
- if (pageFiles.length > 0) {
3042
- for (const f of pageFiles) {
3043
- elements.push({
3044
- tag: 'column_set',
3045
- flex_mode: 'none',
3046
- columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
3047
- });
3048
- }
3049
- }
3050
- else if (visibleFileCount === 0) {
3051
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
3052
- }
3053
- else {
3054
- elements.push({
3055
- tag: 'div',
3056
- text: { tag: 'lark_md', content: `(第 ${currentPage} 页无文件)` },
3057
- });
3058
- }
3059
- }
3060
- // Pagination bar (only shown when there are more items than PAGE_SIZE)
3061
- if (hasPagination) {
3062
- const bar = paginationBar({
3063
- cmd: 'ls.page',
3064
- offset: safeOffset,
3065
- pageSize: CommandRouter.LS_PAGE_SIZE,
3066
- total: totalCount,
3067
- extra: { path: targetDir, root: browseRoot, ...(filter ? { q: filter } : {}) },
3068
- label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
3069
- });
3070
- elements.push({ tag: 'hr' });
3071
- elements.push(...bar);
3072
- }
3073
- return {
3074
- card: {
3075
- schema: '2.0',
3076
- config: { wide_screen_mode: true, update_multi: true },
3077
- header: {
3078
- title: { tag: 'plain_text', content: `📁 ${path.basename(targetDir)}` },
3079
- template: 'blue',
3080
- },
3081
- body: {
3082
- elements,
3083
- },
3084
- },
3085
- };
3086
- }
3087
- catch (err) {
3088
- return { text: `读取目录失败: ${err.message}` };
3089
- }
2771
+ return buildLsCommand(args, ctx, {
2772
+ cwd: this.sessionStore.getCwd(ctx.userId),
2773
+ offset,
2774
+ rootDir,
2775
+ q,
2776
+ });
3090
2777
  }
3091
2778
  /**
3092
2779
  * `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
@@ -3112,278 +2799,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3112
2799
  }
3113
2800
  return this.deliverFileToFeishu(path.resolve(input), ctx);
3114
2801
  }
3115
- /**
3116
- * 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
3117
- *
3118
- * `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
3119
- * 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
3120
- */
3121
- buildFileCard(filePath) {
3122
- let stat;
3123
- try {
3124
- stat = fs.statSync(filePath);
3125
- }
3126
- catch {
3127
- stat = undefined;
3128
- }
3129
- const sizeText = stat ? formatBytes(stat.size) : '未知';
3130
- const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
3131
- const parentDir = path.dirname(filePath);
3132
- const buttons = [
3133
- cardButton('📎 下载', { cmd: 'ls.file', path: filePath }, { type: 'primary', size: 'small' }),
3134
- ];
3135
- if (parentDir !== filePath) {
3136
- buttons.push(
3137
- // 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 工作目录
3138
- cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: parentDir }, { type: 'default', size: 'small' }));
3139
- }
3140
- return {
3141
- card: {
3142
- schema: '2.0',
3143
- config: { wide_screen_mode: true, update_multi: true },
3144
- header: {
3145
- title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
3146
- template: 'blue',
3147
- },
3148
- body: {
3149
- elements: [
3150
- {
3151
- tag: 'div',
3152
- text: {
3153
- tag: 'lark_md',
3154
- content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
3155
- },
3156
- },
3157
- {
3158
- tag: 'column_set',
3159
- columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
3160
- },
3161
- { tag: 'hr' },
3162
- {
3163
- tag: 'div',
3164
- text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
3165
- },
3166
- ],
3167
- },
3168
- },
3169
- };
3170
- }
3171
- /**
3172
- * `/ws` 实现(public:测试直接调用,替代 as unknown as,与 cmdLs 同款)。
3173
- *
3174
- * @param offset list 分支的分页起点(条目数)
3175
- * @param q list 分支的关键词筛选:name 或完整 path 命中即保留,大小写
3176
- * 不敏感。省略/空 = 无筛选。
3177
- */
3178
2802
  cmdWs(args, ctx, offset = 0, q) {
3179
- const sub = args[0]?.toLowerCase();
3180
- switch (sub) {
3181
- case 'save': {
3182
- const name = args[1];
3183
- if (!name)
3184
- return { text: '用法: /ws save <name>' };
3185
- const cwd = this.sessionStore.getCwd(ctx.userId);
3186
- if (!cwd)
3187
- return { text: '请先 /cd 设置工作目录' };
3188
- this.workspaceStore.save(name, cwd);
3189
- return { text: `已保存 workspace "${name}" -> ${cwd}` };
3190
- }
3191
- case 'use': {
3192
- const name = args[1];
3193
- if (!name)
3194
- return { text: '用法: /ws use <name>' };
3195
- const wsPath = this.workspaceStore.get(name);
3196
- if (!wsPath)
3197
- return { text: `workspace "${name}" 不存在` };
3198
- if (!fs.existsSync(wsPath))
3199
- return { text: `路径不存在: ${wsPath}` };
3200
- // Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
3201
- // TOCTOU guard: mirror cmdCd — if the path vanishes
3202
- // between existsSync and realpath, reply gracefully instead of
3203
- // throwing into the queue task's silent .catch.
3204
- let canonical;
3205
- try {
3206
- canonical = fs.realpathSync(wsPath);
3207
- }
3208
- catch {
3209
- return { text: `路径无效: ${wsPath}` };
3210
- }
3211
- // Record usage timestamp AFTER successful path validation
3212
- this.workspaceStore.touch(name);
3213
- // Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
3214
- return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
3215
- }
3216
- case 'remove': {
3217
- const name = args[1];
3218
- if (!name)
3219
- return { text: '用法: /ws remove <name>' };
3220
- if (!this.workspaceStore.has(name))
3221
- return { text: `workspace "${name}" 不存在` };
3222
- this.workspaceStore.remove(name);
3223
- return { text: `已删除 workspace "${name}"` };
3224
- }
3225
- case 'list':
3226
- default: {
3227
- const filter = q?.trim() || undefined;
3228
- const entries = this.workspaceStore.list();
3229
- const currentCwd = this.sessionStore.getCwd(ctx.userId);
3230
- const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
3231
- // Sort entries by the current preference (view concern, not stored)
3232
- if (sortMode === 'recent') {
3233
- // Most recently used first; same lastUsedAt → alphabetical by name (deterministic)
3234
- entries.sort((a, b) => {
3235
- if (b.lastUsedAt !== a.lastUsedAt)
3236
- return b.lastUsedAt - a.lastUsedAt;
3237
- return a.name.localeCompare(b.name);
3238
- });
3239
- }
3240
- else {
3241
- // Alphabetical by alias name
3242
- entries.sort((a, b) => a.name.localeCompare(b.name));
3243
- }
3244
- // 关键词筛选:alias name 或完整 path 命中即保留,大小写不敏感的子串包含
3245
- // (禁止 new RegExp(q)——用户搜 `foo(` 会直接抛)。插在排序之后、分页
3246
- // 之前:先分页再过滤会出现某页只剩 1 条的残页,且翻页时筛选像是丢了。
3247
- const ql = filter?.toLowerCase();
3248
- const visible = ql
3249
- ? entries.filter((e) => e.name.toLowerCase().includes(ql) || e.path.toLowerCase().includes(ql))
3250
- : entries;
3251
- // Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
3252
- // offsets so prev/next always step by WS_PAGE_SIZE.
3253
- const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(visible, offset, WS_PAGE_SIZE);
3254
- // Build body elements: current cwd + cwd list with dividers
3255
- const bodyElements = [];
3256
- // Current working directory section
3257
- bodyElements.push({
3258
- tag: 'div',
3259
- text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
3260
- });
3261
- bodyElements.push({ tag: 'hr' });
3262
- // 搜索行:「当前工作目录」之下、排序行之上(与 /ls 同一视觉动线:主信息
3263
- // 在上、过滤紧随其后、列表在下)
3264
- bodyElements.push(searchBar({
3265
- cmd: 'ws.filter',
3266
- placeholder: '搜索名称或路径,输完回车',
3267
- currentQuery: filter,
3268
- }));
3269
- if (filter) {
3270
- bodyElements.push({
3271
- tag: 'div',
3272
- text: {
3273
- tag: 'lark_md',
3274
- content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 个 workspace(点「清除筛选」取消)`,
3275
- },
3276
- });
3277
- }
3278
- if (entries.length === 0 && !filter) {
3279
- bodyElements.push({
3280
- tag: 'div',
3281
- text: { tag: 'lark_md', content: '没有保存的 workspace' },
3282
- });
3283
- }
3284
- else if (filter && visible.length === 0) {
3285
- bodyElements.push({
3286
- tag: 'div',
3287
- text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
3288
- });
3289
- }
3290
- else {
3291
- // Sort mode indicator + toggle button: placed above the list so the user
3292
- // sees "current mode" and "switch to X" before scanning entries.
3293
- // On narrow (mobile) screens, the label and button must convey both
3294
- // what-is-active and what-clicking-does to avoid ambiguity.
3295
- const currentLabel = sortMode === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
3296
- const switchLabel = sortMode === 'recent' ? '🔤 字母顺序' : '🕐 最近使用';
3297
- bodyElements.push({ tag: 'hr' });
3298
- bodyElements.push({
3299
- tag: 'column_set',
3300
- columns: [
3301
- {
3302
- tag: 'column',
3303
- width: 'weighted',
3304
- weight: 1,
3305
- vertical_align: 'center',
3306
- elements: [
3307
- {
3308
- tag: 'div',
3309
- text: { tag: 'lark_md', content: `排序:${currentLabel}` },
3310
- },
3311
- ],
3312
- },
3313
- {
3314
- tag: 'column',
3315
- width: 'auto',
3316
- elements: [
3317
- cardButton(`切换为 ${switchLabel}`, { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) }, { type: 'default', size: 'small' }),
3318
- ],
3319
- },
3320
- ],
3321
- });
3322
- bodyElements.push({ tag: 'hr' });
3323
- for (const entry of pageEntries) {
3324
- bodyElements.push({
3325
- tag: 'div',
3326
- text: { tag: 'lark_md', content: `**${entry.name}** → \`${entry.path}\`` },
3327
- });
3328
- // Use column_set+column for 2.0 (action tag not supported in 2.0)
3329
- bodyElements.push({
3330
- tag: 'column_set',
3331
- columns: [
3332
- {
3333
- tag: 'column',
3334
- width: 'auto',
3335
- elements: [
3336
- cardButton('切换', {
3337
- cmd: 'ws.use',
3338
- name: entry.name,
3339
- offset: safeOffset,
3340
- ...(filter ? { q: filter } : {}),
3341
- }, { type: 'primary', size: 'small' }),
3342
- ],
3343
- },
3344
- {
3345
- tag: 'column',
3346
- width: 'auto',
3347
- elements: [
3348
- cardButton('删除', {
3349
- cmd: 'ws.remove',
3350
- name: entry.name,
3351
- offset: safeOffset,
3352
- ...(filter ? { q: filter } : {}),
3353
- }, { type: 'danger', size: 'small' }),
3354
- ],
3355
- },
3356
- ],
3357
- });
3358
- bodyElements.push({ tag: 'hr' });
3359
- }
3360
- bodyElements.pop();
3361
- // Pagination bar — separate row below the list (not crowded with sort
3362
- // toggle; mobile-friendly vertical layout).
3363
- if (hasPagination) {
3364
- const bar = paginationBar({
3365
- cmd: 'ws.page',
3366
- offset: safeOffset,
3367
- pageSize: WS_PAGE_SIZE,
3368
- total: totalCount,
3369
- extra: filter ? { q: filter } : {},
3370
- label: `**${currentPage}/${totalPages}**(${totalCount})`,
3371
- prevText: '⬅',
3372
- nextText: '➡',
3373
- });
3374
- bodyElements.push({ tag: 'hr' });
3375
- bodyElements.push(...bar);
3376
- }
3377
- }
3378
- const card = {
3379
- schema: '2.0',
3380
- config: { wide_screen_mode: true, update_multi: true },
3381
- header: { title: { tag: 'plain_text', content: 'Workspaces' } },
3382
- body: { elements: bodyElements },
3383
- };
3384
- return { card };
3385
- }
3386
- }
2803
+ return buildWsCommand(args, ctx, offset, q, {
2804
+ getCwd: (userId) => this.sessionStore.getCwd(userId),
2805
+ saveWorkspace: (name, cwd) => this.workspaceStore.save(name, cwd),
2806
+ getWorkspace: (name) => this.workspaceStore.get(name),
2807
+ hasWorkspace: (name) => this.workspaceStore.has(name),
2808
+ removeWorkspace: (name) => this.workspaceStore.remove(name),
2809
+ touchWorkspace: (name) => this.workspaceStore.touch(name),
2810
+ listWorkspaces: () => this.workspaceStore.list(),
2811
+ getSortMode: (userId) => this.wsSortPreference.get(userId) ?? 'recent',
2812
+ switchCwd: (userId, cwd, actionCtx) => this.switchCwdAndNotify(userId, cwd, actionCtx),
2813
+ });
3387
2814
  }
3388
2815
  /**
3389
2816
  * DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
@@ -3412,286 +2839,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3412
2839
  return { sessionPreset, currentPreset };
3413
2840
  }
3414
2841
  cmdResume(args, ctx, offset = 0) {
3415
- const entry = this.sessionStore.get(ctx.userId);
3416
- const cwd = entry?.cwd;
3417
- // Valid agent kinds for the /resume [agent] [N] feature
3418
- const VALID_AGENTS = CommandRouter.VALID_AGENTS;
3419
- // Parse arguments: /resume [agent] [N] or /resume [sessionId] or /resume [agent] [sessionId]
3420
- let agentKind = this.config.defaultAgent;
3421
- let limit;
3422
- let sessionIdArg;
3423
- if (args[0]) {
3424
- if (VALID_AGENTS.includes(args[0])) {
3425
- agentKind = args[0];
3426
- if (args[1]) {
3427
- if (/^\d+$/.test(args[1])) {
3428
- limit = parseInt(args[1], 10);
3429
- }
3430
- else {
3431
- sessionIdArg = args[1];
3432
- }
3433
- }
3434
- }
3435
- else if (/^\d+$/.test(args[0])) {
3436
- limit = parseInt(args[0], 10);
3437
- }
3438
- else {
3439
- sessionIdArg = args[0];
3440
- }
3441
- }
3442
- // `/resume <sessionId>` — manually set session id (needs an existing cwd to bind to)
3443
- if (sessionIdArg) {
3444
- if (!entry || !cwd)
3445
- return { text: '请先 /cd 设置工作目录' };
3446
- const reader = agentKind !== this.config.defaultAgent
3447
- ? this.sessionReaderRegistry.get(agentKind)
3448
- : this.sessionReader;
3449
- const verifyContent = reader.readSessionContent(sessionIdArg, cwd);
3450
- // If session doesn't exist, reject the write
3451
- if (verifyContent.events.length === 0 &&
3452
- !verifyContent.usage &&
3453
- !verifyContent.aiTitle &&
3454
- !verifyContent.recap &&
3455
- !verifyContent.displayTitle) {
3456
- return {
3457
- text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
3458
- };
3459
- }
3460
- // DSH preset 一致性校验(§6 风险 1):session 的 preset 在创建时固定,
3461
- // 若与当前配置不符,直接复用会让后续 run 报 agent-preset-conflict。
3462
- // 不静默复用——明确提示用户,由用户决定是否继续(配置里手动改/清 preset)。
3463
- if (agentKind === 'dsh' && this.config.defaultAgent === 'dsh') {
3464
- const presetMismatch = this.dshPresetMismatch(reader, sessionIdArg, cwd);
3465
- if (presetMismatch) {
3466
- return {
3467
- text: `⚠️ 该 DSH session 的 preset 是「${presetMismatch.sessionPreset}」,与当前配置「${presetMismatch.currentPreset}」不一致。\n` +
3468
- `preset 在 session 创建时固定,无法中途切换(DSH 返回 agent-preset-conflict)。\n` +
3469
- `继续恢复将在下次 run 失败。请在 /config 中把预设模式改回「${presetMismatch.sessionPreset}」后重试,或保持当前配置并发新消息新建会话。`,
3470
- };
3471
- }
3472
- }
3473
- // 校验通过后才写入
3474
- this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
3475
- const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
3476
- agentKind,
3477
- maxEvents: AUTO_RESUME_MAX_EVENTS,
3478
- });
3479
- // Action buttons: stop (active) + compact (capable runner, not running) + new session
3480
- const actions = this.buildResumeActionButtons({
3481
- isActive,
3482
- activeRunRunId,
3483
- cwd,
3484
- compact: this.canCompactSession(agentKind, cwd)
3485
- ? { sessionId: sessionIdArg, agentKind }
3486
- : undefined,
3487
- newSession: true,
3488
- });
3489
- const card = buildSessionHistoryCard({
3490
- sessionId: sessionIdArg,
3491
- cwd,
3492
- displayTitle,
3493
- aiTitle,
3494
- recap,
3495
- events: content,
3496
- usage,
3497
- }, {
3498
- agentKind,
3499
- headerText: `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`,
3500
- title: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
3501
- usageResult: 'success',
3502
- emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
3503
- actions,
3504
- });
3505
- return { card };
3506
- }
3507
- // `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
3508
- // Use agentKind to select the appropriate sessionReader
3509
- if (!cwd)
3510
- return { text: '请先 /cd 设置工作目录' };
3511
- const pageSize = clampInt(limit ?? RESUME_PAGE_SIZE, 1, RESUME_PAGE_SIZE);
3512
- let reader;
3513
- // Use this.sessionReader when agentKind matches default, otherwise get from registry
3514
- if (agentKind === this.config.defaultAgent) {
3515
- reader = this.sessionReader;
3516
- }
3517
- else {
3518
- reader = this.sessionReaderRegistry.get(agentKind);
3519
- }
3520
- // Fetch the requested page. If the offset is stale (sessions added/removed
3521
- // since the page was rendered), clamp it to the last valid page start and
3522
- // re-fetch so the card never shows a misaligned/empty page.
3523
- let pageOffset = offset;
3524
- let pageResult;
3525
- try {
3526
- pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
3527
- // 末页起点对齐页边界((总页数-1)*pageSize,如 25 条/页 20 → offset 20),
3528
- // 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
3529
- const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
3530
- if (pageOffset < 0 || pageOffset > maxOffset) {
3531
- pageOffset = clampInt(pageOffset, 0, maxOffset);
3532
- pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
3533
- }
3534
- }
3535
- catch (err) {
3536
- // 读取失败必须给用户可见反馈,而不是误导性的「没有 session 记录」
3537
- const msg = err instanceof Error ? err.message : String(err);
3538
- return { text: `读取 ${agentDisplayName(agentKind)} session 列表失败: ${msg}` };
3539
- }
3540
- const { sessions: allSessions, total } = pageResult;
3541
- if (allSessions.length === 0) {
3542
- const agentName = agentDisplayName(agentKind);
3543
- const hint = agentKind === this.config.defaultAgent
3544
- ? `\n提示:可使用 /resume <agent> 切换查看其他 Agent 的 session`
3545
- : '';
3546
- return { text: `当前目录没有 ${agentName} session 记录\n${cwd}${hint}` };
3547
- }
3548
- // Pre-fetch aiTitle/recap/displayTitle only for the first few rows —
3549
- // full reads are expensive (whole-file JSONL scan). Remaining rows fall
3550
- // back to the lightweight summary already returned by listSessions.
3551
- const sessionMeta = new Map();
3552
- const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
3553
- for (const s of allSessions.slice(0, prefetchCount)) {
3554
- // 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
3555
- // DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
3556
- // `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
3557
- // 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
3558
- // `session/list` 就已经给全了。
3559
- if (s.title || s.lastUserMessage || s.lastAssistantMessage)
3560
- continue;
3561
- // 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
3562
- // readSessionSummary,避免对前 N 条各做一次全量 JSONL 解析来构造
3563
- // 立即被丢弃的 events 数组。其他 agent 无此方法时回退全量读取再取字段。
3564
- const meta = reader.readSessionSummary
3565
- ? reader.readSessionSummary(s.sessionId, cwd)
3566
- : (() => {
3567
- const c = reader.readSessionContent(s.sessionId, cwd);
3568
- return { aiTitle: c.aiTitle, recap: c.recap, displayTitle: c.displayTitle };
3569
- })();
3570
- sessionMeta.set(s.sessionId, {
3571
- aiTitle: meta.aiTitle,
3572
- recap: meta.recap,
3573
- displayTitle: meta.displayTitle,
3574
- });
3575
- }
3576
- const now = Date.now();
3577
- function formatRelativeTime(mtimeMs) {
3578
- const diffSec = Math.floor((now - mtimeMs) / 1000);
3579
- if (diffSec < 60)
3580
- return '刚刚';
3581
- if (diffSec < 3600)
3582
- return `${Math.floor(diffSec / 60)} 分钟前`;
3583
- if (diffSec < 86400)
3584
- return `${Math.floor(diffSec / 3600)} 小时前`;
3585
- if (diffSec < 604800)
3586
- return `${Math.floor(diffSec / 86400)} 天前`;
3587
- return new Date(mtimeMs).toLocaleDateString('zh-CN');
3588
- }
3589
- const elements = [
3590
- {
3591
- tag: 'div',
3592
- text: { tag: 'lark_md', content: `📂 当前工作目录:\`${cwd}\`` },
3593
- },
3594
- ];
3595
- // Get current sessionId for the agent being listed
3596
- const currentId = entry?.sessions?.get(agentKind) ?? '';
3597
- for (const s of allSessions) {
3598
- // Only mark as current if we're listing the defaultAgent's sessions
3599
- const isCurrent = agentKind === this.config.defaultAgent && s.sessionId === currentId;
3600
- const timeStr = formatRelativeTime(s.mtime);
3601
- const buttonText = isCurrent ? '✓ 当前会话' : '恢复此会话';
3602
- const meta = sessionMeta.get(s.sessionId);
3603
- // Title priority:
3604
- // 1. 行级 title(服务端投影,DSH)→ 「标题」。
3605
- // 2. Prefetched displayTitle (aiTitle or last user message) keeps its
3606
- // original label (AI 标题 / 最近输入).
3607
- // 3. Otherwise the lightweight summary renders under the neutral
3608
- // "会话摘要" label — summary is the first user message, not the
3609
- // last input, so "最近输入" would be semantically wrong.
3610
- // 4. Placeholder summaries (no user message) render no title section
3611
- // at all — never leak '(无摘要)'/'(no user message)'/'New Session'.
3612
- const summaryIsPlaceholder = RESUME_SUMMARY_PLACEHOLDERS.has(s.summary.trim());
3613
- const prefetchedTitle = meta?.displayTitle;
3614
- // 行级 title 也过占位符过滤:DSH 对空会话可能给 "New Session",旧路径
3615
- // 的 summary 会被挡掉,新路径如果不过滤就会把它当真实标题渲染。
3616
- const rowTitleRaw = s.title?.trim() ?? '';
3617
- const rowTitle = rowTitleRaw && !RESUME_SUMMARY_PLACEHOLDERS.has(rowTitleRaw) ? rowTitleRaw : undefined;
3618
- // aiTitle 不能单独作为"预取标题"键:若 displayTitle 缺失(理论上 opencode
3619
- // info.title 非空但 export 无 user text),标题兜底是 summary,label 必须
3620
- // 是中性"会话摘要"而不是"最近输入"(Review 边缘分支)。
3621
- const titleText = prefetchedTitle ?? rowTitle ?? (summaryIsPlaceholder ? '' : s.summary);
3622
- const titleLabel = prefetchedTitle
3623
- ? meta?.aiTitle
3624
- ? 'AI 标题'
3625
- : '最近输入'
3626
- : rowTitle
3627
- ? '标题'
3628
- : summaryIsPlaceholder
3629
- ? ''
3630
- : '会话摘要';
3631
- const sections = [];
3632
- if (titleText) {
3633
- sections.push(`🏷️ **${titleLabel}**\n${titleText.slice(0, 80)}${titleText.length > 80 ? '...' : ''}`);
3634
- }
3635
- if (meta?.recap) {
3636
- sections.push(`📝 **Recap**\n${meta.recap.slice(0, 60)}${meta.recap.length > 60 ? '...' : ''}`);
3637
- }
3638
- // 行级预览(DSH 服务端投影):最后一句我说了什么 / 最后一句它答了什么。
3639
- // 只对提供了字段的 reader 渲染,其他 agent 的卡片一个字都不变。
3640
- if (s.lastUserMessage) {
3641
- sections.push(`✏️ **最近输入**\n${sessionPreviewLine(s.lastUserMessage, RESUME_PREVIEW_MAX)}`);
3642
- }
3643
- if (s.lastAssistantMessage) {
3644
- sections.push(`📤 **最后输出**\n${sessionPreviewLine(s.lastAssistantMessage, RESUME_PREVIEW_MAX)}`);
3645
- }
3646
- const infoStr = sections.length > 0 ? '\n' + sections.join('\n──\n') : '';
3647
- elements.push({ tag: 'hr' });
3648
- // Both current and non-current sessions get a button for visual consistency
3649
- elements.push({
3650
- tag: 'div',
3651
- text: {
3652
- tag: 'lark_md',
3653
- content: `**${s.sessionId}**${infoStr}\n⏱ ${timeStr}${isCurrent ? ' •' : ''}`,
3654
- },
3655
- });
3656
- elements.push({
3657
- tag: 'column_set',
3658
- columns: [
3659
- {
3660
- tag: 'column',
3661
- width: 'auto',
3662
- elements: [
3663
- cardButton(buttonText, { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind }, { type: isCurrent ? 'default' : 'primary', disabled: isCurrent }),
3664
- ],
3665
- },
3666
- ],
3667
- });
3668
- }
3669
- // Pagination bar (only shown when there are more sessions than pageSize)
3670
- if (total > pageSize) {
3671
- const { totalPages, currentPage } = pageInfo(total, pageOffset, pageSize);
3672
- const bar = paginationBar({
3673
- cmd: 'resume.page',
3674
- offset: pageOffset,
3675
- pageSize,
3676
- total,
3677
- extra: { agent: agentKind, pageSize },
3678
- label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
3679
- });
3680
- elements.push({ tag: 'hr' });
3681
- elements.push(...bar);
3682
- }
3683
- const card = {
3684
- schema: '2.0',
3685
- config: { wide_screen_mode: true },
3686
- header: {
3687
- title: {
3688
- tag: 'plain_text',
3689
- content: `🔁 恢复历史会话 · ${agentDisplayName(agentKind)}`,
3690
- },
3691
- },
3692
- body: { elements },
3693
- };
3694
- return { card };
2842
+ return buildResumeCommand(args, ctx, offset, {
2843
+ defaultAgent: this.config.defaultAgent,
2844
+ validAgents: CommandRouter.VALID_AGENTS,
2845
+ getSessionEntry: (userId) => this.sessionStore.get(userId),
2846
+ setSessionId: (userId, agent, sessionId, cwd) => this.sessionStore.setSessionId(userId, agent, sessionId, cwd),
2847
+ defaultReader: () => this.sessionReader,
2848
+ readerFor: (agent) => this.sessionReaderRegistry.get(agent),
2849
+ dshPresetMismatch: (reader, sessionId, cwd) => this.dshPresetMismatch(reader, sessionId, cwd),
2850
+ readDisplay: (sessionId, cwd, opts) => this.readSessionDisplayState(sessionId, cwd, opts),
2851
+ buildActions: (opts) => this.buildResumeActionButtons(opts),
2852
+ canCompact: (agent, cwd) => this.canCompactSession(agent, cwd),
2853
+ });
3695
2854
  }
3696
2855
  cmdActive(_args, _ctx, offset = 0) {
3697
2856
  // List all active runs from lark-remote memory (not from file system scan)
@@ -3915,216 +3074,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3915
3074
  // 使用共享渲染模块构建卡片(Agent 选择器作为普通 select 字段参与渲染)
3916
3075
  return { card: buildConfigCardFromTabs(tabs, displayConfig) };
3917
3076
  }
3918
- /** /order 实现(public:测试直接调用,替代 as unknown as)。 */
3919
- cmdOrder(args, _ctx, offset = 0) {
3920
- const sub = args[0]?.toLowerCase();
3921
- // /order — list orders (CardKit 2.0 with pagination)
3922
- if (!sub || sub === 'list') {
3923
- this.orderStore.reload();
3924
- const allOrders = this.orderStore.get();
3925
- const elements = [];
3926
- if (allOrders.length === 0) {
3927
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '暂无指令' } });
3928
- }
3929
- else {
3930
- // Pagination calculations
3931
- const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageOrders, hasPagination, } = pageSlice(allOrders, offset, ORDER_PAGE_SIZE);
3932
- for (let i = 0; i < pageOrders.length; i++) {
3933
- const order = pageOrders[i];
3934
- const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
3935
- // 文字在上方(有别名时用 `$name` 前缀标识触发词)
3936
- elements.push({
3937
- tag: 'div',
3938
- text: {
3939
- tag: 'lark_md',
3940
- content: order.alias ? `\`$${order.alias}\` ${displayText}` : displayText,
3941
- },
3942
- });
3943
- // 操作行:左侧别名按钮(无别名显示 +别名,有别名点击可改),
3944
- // 右侧「删除指令」按钮。两列均 weighted 1:1,跨行对齐。
3945
- const leftColumn = order.alias
3946
- ? [
3947
- cardButton(`$${order.alias}`, { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
3948
- cardButton('✕', {
3949
- cmd: 'order.aliasRemove',
3950
- orderId: order.id,
3951
- aliasName: order.alias,
3952
- offset: safeOffset,
3953
- }, { type: 'default', size: 'small' }),
3954
- ]
3955
- : [
3956
- cardButton('+别名', { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
3957
- ];
3958
- elements.push({
3959
- tag: 'column_set',
3960
- columns: [
3961
- {
3962
- tag: 'column',
3963
- width: 'weighted',
3964
- weight: 1,
3965
- elements: leftColumn,
3966
- },
3967
- {
3968
- tag: 'column',
3969
- width: 'weighted',
3970
- weight: 1,
3971
- vertical_align: 'center',
3972
- elements: [
3973
- {
3974
- tag: 'column_set',
3975
- columns: [
3976
- {
3977
- tag: 'column',
3978
- width: 'auto',
3979
- elements: [
3980
- cardButton('▶ 执行', { cmd: 'order.exec', orderId: order.id }, { type: 'primary', size: 'small' }),
3981
- ],
3982
- },
3983
- {
3984
- // 编辑:弹蓝色 header 输入卡(CardKit 2.0 input,回车提交),
3985
- // 镜像 order.aliasEdit 流程。order.textEdit 是 control-only action
3986
- // (isImmediateAction 注册),不 spawn agent。
3987
- tag: 'column',
3988
- width: 'auto',
3989
- elements: [
3990
- cardButton('编辑', { cmd: 'order.textEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
3991
- ],
3992
- },
3993
- {
3994
- tag: 'column',
3995
- width: 'auto',
3996
- elements: [
3997
- cardButton('删除', { cmd: 'order.delete', orderId: order.id, offset: safeOffset }, { type: 'danger', size: 'small' }),
3998
- ],
3999
- },
4000
- ],
4001
- },
4002
- ],
4003
- },
4004
- ],
4005
- });
4006
- // 非最后一项,添加分隔线
4007
- if (i < pageOrders.length - 1) {
4008
- elements.push({ tag: 'hr' });
4009
- }
4010
- }
4011
- // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
4012
- if (hasPagination) {
4013
- const bar = paginationBar({
4014
- cmd: 'order.page',
4015
- offset: safeOffset,
4016
- pageSize: ORDER_PAGE_SIZE,
4017
- total: totalCount,
4018
- label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
4019
- });
4020
- elements.push({ tag: 'hr' });
4021
- elements.push(...bar);
4022
- }
4023
- }
4024
- return {
4025
- card: {
4026
- schema: '2.0',
4027
- config: { wide_screen_mode: true, update_multi: true },
4028
- header: { title: { tag: 'plain_text', content: '📋 指令' }, template: 'turquoise' },
4029
- body: { elements },
4030
- },
4031
- };
4032
- }
4033
- // /order alias — CLI 别名管理(卡片是主入口,命令是备选):
4034
- // /order alias add <orderId|N> <name> — 给某条指令绑定别名
4035
- // /order alias rm <orderId|N> — 移除某条指令的别名
4036
- // /order alias list — 列出已绑定的别名(并入列表卡片)
4037
- if (sub === 'alias') {
4038
- const op = args[1];
4039
- if (op === 'add') {
4040
- const target = args[2];
4041
- const name = args[3];
4042
- if (!target || !name) {
4043
- return { text: '用法: /order alias add <orderId|序号> <别名名>' };
4044
- }
4045
- return this.cmdOrderAliasAdd(target, name);
4046
- }
4047
- if (op === 'rm') {
4048
- const target = args[2];
4049
- if (!target) {
4050
- return { text: '用法: /order alias rm <orderId|序号>' };
4051
- }
4052
- return this.cmdOrderAliasRemove(target);
4053
- }
4054
- if (!op || op === 'list') {
4055
- // 别名管理列表并入列表卡片
4056
- return this.cmdOrder([], _ctx, offset);
4057
- }
4058
- return {
4059
- text: '用法: /order alias [add <orderId|序号> <别名名> | rm <orderId|序号> | list]',
4060
- };
4061
- }
4062
- // /order save <text>
4063
- if (sub === 'save') {
4064
- const text = args.slice(1).join(' ');
4065
- if (!text) {
4066
- return { text: '用法: /order save <指令文本>' };
4067
- }
4068
- try {
4069
- this.orderStore.save(text);
4070
- return { text: `✅ 已保存指令: ${text.slice(0, 50)}${text.length > 50 ? '...' : ''}` };
4071
- }
4072
- catch (err) {
4073
- return { text: `保存失败: ${err.message}` };
4074
- }
4075
- }
4076
- // /order edit <orderId|序号> <新文本> — CLI 备选路径(卡片是主入口)。
4077
- // 与 /order save / alias add|rm 对称;复用 resolveOrderTarget。
4078
- // 与卡片路径(handleOrderTextInput)对齐 trim 语义,避免「卡片 trim、CLI 不 trim」
4079
- // 两条入口对同一文本存出不同结果。
4080
- if (sub === 'edit') {
4081
- const target = args[1];
4082
- const newText = args.slice(2).join(' ').trim();
4083
- if (!target || !newText) {
4084
- return { text: '用法: /order edit <orderId|序号> <新文本>' };
4085
- }
4086
- const order = this.resolveOrderTarget(target);
4087
- if (!order)
4088
- return { text: `指令不存在: ${target}` };
4089
- try {
4090
- this.orderStore.updateText(order.id, newText);
4091
- const preview = newText.length > 50 ? newText.slice(0, 50) + '...' : newText;
4092
- return { text: `✅ 已更新指令: ${preview}` };
4093
- }
4094
- catch (err) {
4095
- return { text: `保存失败: ${err.message}` };
4096
- }
4097
- }
4098
- return {
4099
- text: '用法: /order [list|save <text>|edit <orderId|序号> <新文本>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
4100
- };
4101
- }
4102
- /** /order alias add:按 orderId 或列表序号(1 基)解析目标指令并绑定别名。 */
4103
- cmdOrderAliasAdd(target, name) {
4104
- const order = this.resolveOrderTarget(target);
4105
- if (!order)
4106
- return { text: `指令不存在: ${target}` };
4107
- try {
4108
- this.orderStore.setAlias(order.id, name);
4109
- return { text: `✅ 已给指令绑定别名 $${name}` };
4110
- }
4111
- catch (err) {
4112
- return { text: `保存失败: ${err.message}` };
4113
- }
4114
- }
4115
- /** /order alias rm:按 orderId 或列表序号(1 基)移除指令的别名。 */
4116
- cmdOrderAliasRemove(target) {
4117
- const order = this.resolveOrderTarget(target);
4118
- if (!order)
4119
- return { text: `指令不存在: ${target}` };
4120
- if (!order.alias)
4121
- return { text: '该指令没有别名' };
4122
- // 先读别名名再解绑:setAlias(undefined) 会 delete 同一对象引用上的 alias 字段。
4123
- const aliasName = order.alias;
4124
- this.orderStore.setAlias(order.id, undefined);
4125
- return { text: `✅ 已移除别名 $${aliasName}` };
3077
+ /** /order 实现(public:测试直接调用)。 */
3078
+ cmdOrder(args, ctx, offset = 0) {
3079
+ return buildOrderCommand(args, ctx, offset, {
3080
+ reload: () => this.orderStore.reload(),
3081
+ list: () => this.orderStore.get(),
3082
+ save: (text) => this.orderStore.save(text),
3083
+ updateText: (id, text) => this.orderStore.updateText(id, text),
3084
+ setAlias: (id, alias) => this.orderStore.setAlias(id, alias),
3085
+ resolveTarget: (target) => this.resolveOrderTarget(target),
3086
+ });
4126
3087
  }
4127
- /** 将 `/order alias add|rm` 的目标解析为 order:优先 orderId 精确匹配,回退列表序号(1 基)。 */
4128
3088
  resolveOrderTarget(target) {
4129
3089
  const all = this.orderStore.get();
4130
3090
  const byId = all.find((o) => o.id === target);