lark-remote 0.5.12 → 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 (95) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/bridge/approval-coordinator.d.ts +1 -0
  5. package/dist/bridge/approval-coordinator.js +12 -24
  6. package/dist/bridge/index.d.ts +7 -0
  7. package/dist/bridge/index.js +77 -227
  8. package/dist/bridge/run-approval.d.ts +15 -0
  9. package/dist/bridge/run-approval.js +41 -0
  10. package/dist/bridge/run-usage.d.ts +46 -0
  11. package/dist/bridge/run-usage.js +96 -0
  12. package/dist/bridge/session-writeback.d.ts +18 -0
  13. package/dist/bridge/session-writeback.js +36 -0
  14. package/dist/card/bash-card-session.js +2 -5
  15. package/dist/card/card-session.d.ts +2 -0
  16. package/dist/card/card-session.js +20 -22
  17. package/dist/card/card-shared.d.ts +4 -4
  18. package/dist/card/card-shared.js +3 -9
  19. package/dist/card/collapsible.js +3 -2
  20. package/dist/card/final-answer-overflow.d.ts +7 -0
  21. package/dist/card/final-answer-overflow.js +46 -0
  22. package/dist/card/run-card-session.js +2 -7
  23. package/dist/card/run-renderer.d.ts +14 -0
  24. package/dist/card/run-renderer.js +119 -1
  25. package/dist/card/run-state.d.ts +12 -1
  26. package/dist/card/run-state.js +73 -34
  27. package/dist/config/codex-config.js +4 -4
  28. package/dist/config/index.d.ts +4 -2
  29. package/dist/config/index.js +6 -4
  30. package/dist/inbound/turn-assembler.js +6 -0
  31. package/dist/index.js +15 -409
  32. package/dist/message-handlers.d.ts +26 -0
  33. package/dist/message-handlers.js +275 -0
  34. package/dist/router/card-action-payload.d.ts +1 -1
  35. package/dist/router/card-action-payload.js +1 -1
  36. package/dist/router/commands/ls.d.ts +9 -0
  37. package/dist/router/commands/ls.js +243 -0
  38. package/dist/router/commands/order.d.ts +12 -0
  39. package/dist/router/commands/order.js +178 -0
  40. package/dist/router/commands/resume.d.ts +47 -0
  41. package/dist/router/commands/resume.js +246 -0
  42. package/dist/router/commands/ws.d.ts +15 -0
  43. package/dist/router/commands/ws.js +189 -0
  44. package/dist/router/config/claude.js +3 -3
  45. package/dist/router/config/codex.js +2 -6
  46. package/dist/router/config/common/turn-idle.d.ts +2 -0
  47. package/dist/router/config/common/turn-idle.js +9 -0
  48. package/dist/router/config/kimi.js +2 -6
  49. package/dist/router/config/opencode.js +2 -6
  50. package/dist/router/index.d.ts +14 -22
  51. package/dist/router/index.js +70 -1152
  52. package/dist/router/paging.d.ts +13 -0
  53. package/dist/router/paging.js +20 -0
  54. package/dist/router/path-validation.d.ts +27 -0
  55. package/dist/router/path-validation.js +45 -0
  56. package/dist/runner/claude/runner.d.ts +3 -4
  57. package/dist/runner/claude/runner.js +4 -5
  58. package/dist/runner/claude/session.js +1 -1
  59. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  60. package/dist/runner/codex/app-server/translator.js +65 -9
  61. package/dist/runner/common/acp/base-translator.d.ts +3 -8
  62. package/dist/runner/common/acp/base-translator.js +16 -7
  63. package/dist/runner/common/spawning-runner.js +10 -4
  64. package/dist/runner/common/turn-started.d.ts +2 -0
  65. package/dist/runner/common/turn-started.js +10 -0
  66. package/dist/runner/dsh/auth.js +2 -3
  67. package/dist/runner/dsh/client.d.ts +1 -6
  68. package/dist/runner/dsh/client.js +11 -165
  69. package/dist/runner/dsh/daemon.d.ts +0 -2
  70. package/dist/runner/dsh/daemon.js +1 -6
  71. package/dist/runner/dsh/errors.d.ts +5 -0
  72. package/dist/runner/dsh/errors.js +11 -0
  73. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  74. package/dist/runner/dsh/exit-cancel.js +1 -1
  75. package/dist/runner/dsh/home.d.ts +2 -0
  76. package/dist/runner/dsh/home.js +8 -0
  77. package/dist/runner/dsh/inflight.d.ts +0 -2
  78. package/dist/runner/dsh/inflight.js +1 -1
  79. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  80. package/dist/runner/dsh/mux-stream.js +157 -0
  81. package/dist/runner/dsh/translator.d.ts +0 -10
  82. package/dist/runner/dsh/translator.js +1 -1
  83. package/dist/runner/index.d.ts +1 -1
  84. package/dist/runner/kimi/acp/translator.js +1 -12
  85. package/dist/runner/opencode/acp/translator.js +1 -12
  86. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  87. package/dist/runner/pi/rpc/translator.js +2 -7
  88. package/dist/runner/registry.d.ts +0 -19
  89. package/dist/runner/registry.js +0 -32
  90. package/dist/runner/types.d.ts +17 -1
  91. package/dist/session/claude/session-index.js +4 -2
  92. package/dist/session/kimi/sessions.js +2 -2
  93. package/dist/session/opencode/sessions.js +2 -1
  94. package/dist/session/pi/sessions.js +3 -2
  95. 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.
@@ -369,20 +329,6 @@ export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
369
329
  function payloadOffset(value) {
370
330
  return Math.max(0, Math.trunc(Number(value.offset) || 0));
371
331
  }
372
- /**
373
- * existsSync + statSync 的 TOCTOU 安全目录校验:路径在两次 syscalls 之间被删时
374
- * statSync 会抛错——落进 enqueueImmediate / 队列任务的静默 catch,点击/命令
375
- * 毫无反馈(违反「点击必有反馈」红线;同族 handleLsRefresh/Page/Filter 均已
376
- * try/catch 防护)。统一返回 false 走「路径无效」回复。
377
- */
378
- function isDirectorySafe(p) {
379
- try {
380
- return fs.existsSync(p) && fs.statSync(p).isDirectory();
381
- }
382
- catch {
383
- return false;
384
- }
385
- }
386
332
  /**
387
333
  * 搜索框提交值单源读取:`inputValue` 主路(CardKit 2.0 input 提交(键盘回车/
388
334
  * 完成键)经 raw `action.input_value` 回传,SDK normalizer 会丢弃
@@ -1226,26 +1172,17 @@ export class CommandRouter {
1226
1172
  const resolvedTarget = path.resolve(targetPath);
1227
1173
  // 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
1228
1174
  // 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
1229
- if (!isDirectorySafe(resolvedTarget)) {
1230
- await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1231
- return;
1232
- }
1233
- // Canonicalize via realpath; if the target vanished between stat and
1234
- // realpath (TOCTOU), reject gracefully instead of throwing.
1235
- let canonical;
1236
- try {
1237
- canonical = fs.realpathSync(resolvedTarget);
1238
- }
1239
- catch {
1175
+ const resolution = resolveDirectory(resolvedTarget);
1176
+ if (!resolution.ok) {
1240
1177
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1241
1178
  return;
1242
1179
  }
1243
1180
  // Set cwd + auto-resume + user notification (shared with /cd and /ws use)
1244
- const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
1181
+ const notifyResult = this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
1245
1182
  // Card channel: refresh ls card in-place (existing behavior preserved)。
1246
1183
  // value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
1247
1184
  // 「返回」仍回到用户最初 /ls 的那个目录。
1248
- const card = this.cmdLs([canonical], ctx, 0, value.root);
1185
+ const card = this.cmdLs([resolution.canonical], ctx, 0, value.root);
1249
1186
  await this.bridge.updateCardInPlace(card.card, ctx);
1250
1187
  // Message channel: send switch confirmation / auto-resume card to chat
1251
1188
  await this.bridge.sendResult(notifyResult, ctx);
@@ -1256,21 +1193,11 @@ export class CommandRouter {
1256
1193
  async handleLsRefresh(value, ctx) {
1257
1194
  const targetPath = value.path;
1258
1195
  const offset = value.offset ?? 0;
1259
- // TOCTOU guard: existsSync → statSync can race (target
1260
- // deleted in between), throwing into enqueueImmediate's catch which only
1261
- // logs — violating the "card button clicks must give visible feedback"
1262
- // red line. Treat a vanished/non-dir path as "use current cwd" instead of
1263
- // crashing the refresh.
1264
- let card;
1265
- try {
1266
- card =
1267
- targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
1268
- ? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
1269
- : this.cmdLs([], ctx, offset, value.root, value.q);
1270
- }
1271
- catch {
1272
- card = this.cmdLs([], ctx, offset, value.root, value.q);
1273
- }
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);
1274
1201
  await this.bridge.updateCardInPlace(card.card, ctx);
1275
1202
  }
1276
1203
  /**
@@ -1281,22 +1208,12 @@ export class CommandRouter {
1281
1208
  if (!targetPath) {
1282
1209
  return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
1283
1210
  }
1284
- const paging = this.pagingOffset(value, CommandRouter.LS_PAGE_SIZE);
1211
+ const paging = this.pagingOffset(value, LS_PAGE_SIZE);
1285
1212
  if ('error' in paging) {
1286
1213
  return { toast: { type: 'error', content: paging.error } };
1287
1214
  }
1288
1215
  const resolvedTarget = path.resolve(targetPath);
1289
- // TOCTOU guard: existsSync → statSync can race; without the
1290
- // try/catch the throw lands in enqueueImmediate's silent .catch and the
1291
- // paginated card never updates (no visible feedback for the click).
1292
- let isDir;
1293
- try {
1294
- isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
1295
- }
1296
- catch {
1297
- isDir = false;
1298
- }
1299
- if (!isDir) {
1216
+ if (!isDirectorySafe(resolvedTarget)) {
1300
1217
  return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
1301
1218
  }
1302
1219
  const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root, value.q);
@@ -1318,14 +1235,7 @@ export class CommandRouter {
1318
1235
  }
1319
1236
  const q = searchQueryFrom(value);
1320
1237
  const resolvedTarget = path.resolve(targetPath);
1321
- let isDir;
1322
- try {
1323
- isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
1324
- }
1325
- catch {
1326
- isDir = false;
1327
- }
1328
- if (!isDir) {
1238
+ if (!isDirectorySafe(resolvedTarget)) {
1329
1239
  return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
1330
1240
  }
1331
1241
  const card = this.cmdLs([resolvedTarget], ctx, 0, value.root, q);
@@ -1856,16 +1766,13 @@ export class CommandRouter {
1856
1766
  * `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
1857
1767
  */
1858
1768
  async deliverFileToFeishu(resolved, ctx) {
1859
- if (!fs.existsSync(resolved)) {
1860
- return { text: `文件不存在: ${resolved}` };
1861
- }
1862
- let stat;
1863
- try {
1864
- stat = fs.statSync(resolved);
1865
- }
1866
- catch {
1769
+ const resolution = statPath(resolved);
1770
+ if (!resolution.ok) {
1771
+ if (resolution.reason === 'missing')
1772
+ return { text: `文件不存在: ${resolved}` };
1867
1773
  return { text: `无法读取: ${resolved}` };
1868
1774
  }
1775
+ const { stat } = resolution;
1869
1776
  if (stat.isDirectory()) {
1870
1777
  return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
1871
1778
  }
@@ -2629,38 +2536,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2629
2536
  const currentCwd = this.sessionStore.getCwd(ctx.userId) ?? process.cwd();
2630
2537
  const expanded = target.startsWith('~') ? path.join(os.homedir(), target.slice(1)) : target;
2631
2538
  const resolved = path.resolve(currentCwd, expanded);
2632
- if (!fs.existsSync(resolved)) {
2633
- return { text: `路径不存在: ${resolved}` };
2634
- }
2635
- // TOCTOU:existsSync 与 statSync 之间路径被删时 statSync 抛错,会落进队列
2636
- // 任务的静默 catch(用户无反馈)——按路径无效回复,与下方 realpath 守卫同口径。
2637
- let stat;
2638
- try {
2639
- stat = fs.statSync(resolved);
2640
- }
2641
- catch {
2642
- return { text: `路径无效: ${resolved}` };
2643
- }
2644
- if (!stat.isDirectory()) {
2645
- return { text: `不是目录: ${resolved}` };
2646
- }
2647
- // Canonicalize via realpath so it matches the symlink-resolved cwd that
2648
- // Claude writes into JSONL (e.g. `/tmp` → `/private/tmp` on macOS).
2649
- // Without this, a hand-typed unresolved path never matches any JSONL's
2650
- // cwd field and the user's `/cd /tmp/foo` never finds their sessions.
2651
- // TOCTOU: the directory may vanish between stat and realpath.
2652
- // Reject gracefully instead of letting realpathSync throw into the queue
2653
- // task's .catch (which only logs — the user gets no feedback). Mirrors
2654
- // handleLsSwitch's realpath guard so /cd and ls.switch behave identically.
2655
- let canonical;
2656
- try {
2657
- canonical = fs.realpathSync(resolved);
2658
- }
2659
- catch {
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}` };
2660
2545
  return { text: `路径无效: ${resolved}` };
2661
2546
  }
2662
2547
  // Switch cwd with auto-resume and user feedback (shared with /ws use and ls.switch)
2663
- return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
2548
+ return this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
2664
2549
  }
2665
2550
  readSessionDisplayState(sessionId, cwd, opts) {
2666
2551
  // Use the appropriate session reader based on agentKind
@@ -2871,7 +2756,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2871
2756
  }
2872
2757
  return actions;
2873
2758
  }
2874
- static LS_PAGE_SIZE = 30;
2875
2759
  /**
2876
2760
  * `/ls` 实现(public:测试直接调用,替代 as unknown as)。
2877
2761
  *
@@ -2884,246 +2768,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2884
2768
  * 省略/空 = 无筛选。
2885
2769
  */
2886
2770
  cmdLs(args, ctx, offset = 0, rootDir, q) {
2887
- const cwd = this.sessionStore.getCwd(ctx.userId);
2888
- if (!cwd) {
2889
- return { text: '请先使用 /cd <path> 设置工作目录' };
2890
- }
2891
- const filter = q?.trim() || undefined;
2892
- // args can be:
2893
- // - [] : list current cwd
2894
- // - [dirName] : list subdirectory if it exists
2895
- let targetDir = cwd;
2896
- if (args[0]) {
2897
- // Expand tilde to home directory (path.resolve does not handle ~)
2898
- if (args[0].startsWith('~')) {
2899
- args[0] = path.join(os.homedir(), args[0].slice(1));
2900
- }
2901
- // path.resolve (not path.join) so absolute args[0] is honored as-is
2902
- // instead of being concatenated onto cwd (which broke /ls card nav).
2903
- const potentialDir = path.resolve(cwd, args[0]);
2904
- if (!fs.existsSync(potentialDir)) {
2905
- return { text: `ls: ${args[0]}: No such file or directory` };
2906
- }
2907
- let isDir;
2908
- try {
2909
- isDir = fs.statSync(potentialDir).isDirectory();
2910
- }
2911
- catch {
2912
- // TOCTOU:existsSync 与 statSync 之间目标被删
2913
- return { text: `ls: ${args[0]}: No such file or directory` };
2914
- }
2915
- if (!isDir) {
2916
- // 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
2917
- // 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
2918
- return this.buildFileCard(potentialDir);
2919
- }
2920
- targetDir = potentialDir;
2921
- }
2922
- // 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
2923
- // 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
2924
- // 列出的目录,因此「返回」永远不会把用户丢回工作目录。
2925
- const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
2926
- try {
2927
- const entries = fs.readdirSync(targetDir, { withFileTypes: true });
2928
- // 逐条目分类。`Dirent.isFile()/isDirectory()` 对符号链接恒 false(旧实现
2929
- // 因此完全不列链接);必须 stat 解析目标。单条目 stat 失败(悬空链接、
2930
- // readdir 与 stat 之间被删、无权限)只让该条目降级为「大小未知」,不能
2931
- // 让整次列目录失败——一个坏邻居毁掉整张卡是旧实现的第二宗罪。
2932
- const dirs = [];
2933
- const files = [];
2934
- for (const entry of entries) {
2935
- let isDir = entry.isDirectory();
2936
- let size;
2937
- if (!isDir) {
2938
- try {
2939
- const st = fs.statSync(path.join(targetDir, entry.name));
2940
- isDir = st.isDirectory();
2941
- if (!isDir)
2942
- size = st.size;
2943
- }
2944
- catch {
2945
- // 目标拿不到:按文件列出,size 留空
2946
- }
2947
- }
2948
- if (isDir)
2949
- dirs.push(entry.name);
2950
- else
2951
- files.push({ name: entry.name, size });
2952
- }
2953
- dirs.sort((a, b) => a.localeCompare(b));
2954
- files.sort((a, b) => a.name.localeCompare(b.name));
2955
- // Merge dirs and files for pagination, preserving type info
2956
- const allItems = [
2957
- ...dirs.map((name) => ({ name, isDir: true })),
2958
- ...files.map((f) => ({ name: f.name, isDir: false, size: f.size })),
2959
- ];
2960
- // 关键词筛选:只匹配当前层条目名(不递归子目录)。大小写不敏感的子串包含
2961
- // ——禁止 new RegExp(q)(用户搜 `foo(` 会直接抛)。插在排序之后、分页之前。
2962
- const ql = filter?.toLowerCase();
2963
- const visibleItems = ql
2964
- ? allItems.filter((i) => i.name.toLowerCase().includes(ql))
2965
- : allItems;
2966
- const visibleDirCount = visibleItems.reduce((n, i) => n + (i.isDir ? 1 : 0), 0);
2967
- const visibleFileCount = visibleItems.length - visibleDirCount;
2968
- // Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
2969
- const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(visibleItems, offset, CommandRouter.LS_PAGE_SIZE);
2970
- // Check if we need to show parent directory button
2971
- const parentDir = path.dirname(targetDir);
2972
- const hasParent = parentDir !== targetDir;
2973
- // 「切换」相对 cwd(把工作目录切到当前浏览目录)
2974
- const isSubdir = targetDir !== cwd;
2975
- // 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
2976
- const canReturn = targetDir !== browseRoot;
2977
- // Build header with parent button
2978
- const headerElements = [];
2979
- if (hasParent) {
2980
- headerElements.push(cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: browseRoot }, { type: 'default', size: 'small' }));
2981
- }
2982
- // Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
2983
- if (canReturn) {
2984
- headerElements.push(cardButton('返回', { cmd: 'ls.browse', path: browseRoot, root: browseRoot }, { type: 'default', size: 'small' }));
2985
- }
2986
- headerElements.push(cardButton('刷新', {
2987
- cmd: 'ls.refresh',
2988
- path: targetDir,
2989
- offset: safeOffset,
2990
- root: browseRoot,
2991
- ...(filter ? { q: filter } : {}),
2992
- }, { type: 'default', size: 'small' }));
2993
- // Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
2994
- if (isSubdir) {
2995
- headerElements.push(
2996
- // 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
2997
- // 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
2998
- cardButton('切换', { cmd: 'ls.switch', path: targetDir, root: browseRoot }, { type: 'primary', size: 'small' }));
2999
- }
3000
- // Helper to create directory button - use targetDir
3001
- const dirButton = (name) => cardButton(`📁 ${name}`, { cmd: 'ls.browse', path: path.join(targetDir, name), root: browseRoot }, { type: 'default' });
3002
- // Helper to create file button with size - use targetDir
3003
- const fileButton = (name, size) => {
3004
- const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
3005
- return cardButton(`📄 ${name}${sizeStr}`, { cmd: 'ls.file', path: path.join(targetDir, name) }, { type: 'default', size: 'small' });
3006
- };
3007
- // Build each section's elements (no tabs - use section headers)
3008
- // Note: div+elements is not supported in CardKit 2.0, use column_set instead
3009
- const elements = [];
3010
- // Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
3011
- // 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。筛选态一律用
3012
- // 过滤后的计数,否则标题说 12 项、列表只有 3 项,卡片自相矛盾。
3013
- const status = `\n共 ${visibleDirCount} 目录, ${visibleFileCount} 文件`;
3014
- // Header info + navigation buttons - show targetDir in header
3015
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
3016
- elements.push({
3017
- tag: 'column_set',
3018
- columns: headerElements.map((e) => ({ tag: 'column', width: 'auto', elements: [e] })),
3019
- });
3020
- elements.push({ tag: 'hr' });
3021
- // 搜索行:头部导航按钮之下、内容区之上(主导航在上、过滤紧随其后、内容区
3022
- // 在下)。path/root 必须进 callback value,否则提交后 handler 不知道刷哪。
3023
- elements.push(searchBar({
3024
- cmd: 'ls.filter',
3025
- placeholder: '搜索本层目录/文件名,输完回车',
3026
- currentQuery: filter,
3027
- extra: { path: targetDir, root: browseRoot },
3028
- }));
3029
- if (filter) {
3030
- elements.push({
3031
- tag: 'div',
3032
- text: {
3033
- tag: 'lark_md',
3034
- content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 项(点「清除筛选」取消)`,
3035
- },
3036
- });
3037
- }
3038
- // Section 1 + 2: Directories / Files (show only items on current page)
3039
- const pageDirs = pageItems.filter((i) => i.isDir);
3040
- const pageFiles = pageItems.filter((i) => !i.isDir);
3041
- if (filter && visibleItems.length === 0) {
3042
- // 筛选零命中:两个区标题都不渲染(两行空区标题只是噪音),搜索行仍在,
3043
- // 用户可直接改词再回车,或点「清除筛选」。
3044
- elements.push({
3045
- tag: 'div',
3046
- text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
3047
- });
3048
- }
3049
- else {
3050
- elements.push({
3051
- tag: 'div',
3052
- text: { tag: 'lark_md', content: `**📂 目录 (${visibleDirCount})**` },
3053
- });
3054
- if (pageDirs.length > 0) {
3055
- for (const d of pageDirs) {
3056
- elements.push({
3057
- tag: 'column_set',
3058
- flex_mode: 'none',
3059
- columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
3060
- });
3061
- }
3062
- }
3063
- else if (visibleDirCount === 0) {
3064
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
3065
- }
3066
- else {
3067
- elements.push({
3068
- tag: 'div',
3069
- text: { tag: 'lark_md', content: `(第 ${currentPage} 页无目录)` },
3070
- });
3071
- }
3072
- elements.push({ tag: 'hr' });
3073
- // Section 2: Files
3074
- elements.push({
3075
- tag: 'div',
3076
- text: { tag: 'lark_md', content: `**📄 文件 (${visibleFileCount})**` },
3077
- });
3078
- if (pageFiles.length > 0) {
3079
- for (const f of pageFiles) {
3080
- elements.push({
3081
- tag: 'column_set',
3082
- flex_mode: 'none',
3083
- columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
3084
- });
3085
- }
3086
- }
3087
- else if (visibleFileCount === 0) {
3088
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
3089
- }
3090
- else {
3091
- elements.push({
3092
- tag: 'div',
3093
- text: { tag: 'lark_md', content: `(第 ${currentPage} 页无文件)` },
3094
- });
3095
- }
3096
- }
3097
- // Pagination bar (only shown when there are more items than PAGE_SIZE)
3098
- if (hasPagination) {
3099
- const bar = paginationBar({
3100
- cmd: 'ls.page',
3101
- offset: safeOffset,
3102
- pageSize: CommandRouter.LS_PAGE_SIZE,
3103
- total: totalCount,
3104
- extra: { path: targetDir, root: browseRoot, ...(filter ? { q: filter } : {}) },
3105
- label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
3106
- });
3107
- elements.push({ tag: 'hr' });
3108
- elements.push(...bar);
3109
- }
3110
- return {
3111
- card: {
3112
- schema: '2.0',
3113
- config: { wide_screen_mode: true, update_multi: true },
3114
- header: {
3115
- title: { tag: 'plain_text', content: `📁 ${path.basename(targetDir)}` },
3116
- template: 'blue',
3117
- },
3118
- body: {
3119
- elements,
3120
- },
3121
- },
3122
- };
3123
- }
3124
- catch (err) {
3125
- return { text: `读取目录失败: ${err.message}` };
3126
- }
2771
+ return buildLsCommand(args, ctx, {
2772
+ cwd: this.sessionStore.getCwd(ctx.userId),
2773
+ offset,
2774
+ rootDir,
2775
+ q,
2776
+ });
3127
2777
  }
3128
2778
  /**
3129
2779
  * `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
@@ -3149,283 +2799,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3149
2799
  }
3150
2800
  return this.deliverFileToFeishu(path.resolve(input), ctx);
3151
2801
  }
3152
- /**
3153
- * 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
3154
- *
3155
- * `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
3156
- * 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
3157
- */
3158
- buildFileCard(filePath) {
3159
- let stat;
3160
- try {
3161
- stat = fs.statSync(filePath);
3162
- }
3163
- catch {
3164
- stat = undefined;
3165
- }
3166
- const sizeText = stat ? formatBytes(stat.size) : '未知';
3167
- const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
3168
- const parentDir = path.dirname(filePath);
3169
- const buttons = [
3170
- cardButton('📎 下载', { cmd: 'ls.file', path: filePath }, { type: 'primary', size: 'small' }),
3171
- ];
3172
- if (parentDir !== filePath) {
3173
- buttons.push(
3174
- // 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 工作目录
3175
- cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: parentDir }, { type: 'default', size: 'small' }));
3176
- }
3177
- return {
3178
- card: {
3179
- schema: '2.0',
3180
- config: { wide_screen_mode: true, update_multi: true },
3181
- header: {
3182
- title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
3183
- template: 'blue',
3184
- },
3185
- body: {
3186
- elements: [
3187
- {
3188
- tag: 'div',
3189
- text: {
3190
- tag: 'lark_md',
3191
- content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
3192
- },
3193
- },
3194
- {
3195
- tag: 'column_set',
3196
- columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
3197
- },
3198
- { tag: 'hr' },
3199
- {
3200
- tag: 'div',
3201
- text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
3202
- },
3203
- ],
3204
- },
3205
- },
3206
- };
3207
- }
3208
- /**
3209
- * `/ws` 实现(public:测试直接调用,替代 as unknown as,与 cmdLs 同款)。
3210
- *
3211
- * @param offset list 分支的分页起点(条目数)
3212
- * @param q list 分支的关键词筛选:name 或完整 path 命中即保留,大小写
3213
- * 不敏感。省略/空 = 无筛选。
3214
- */
3215
2802
  cmdWs(args, ctx, offset = 0, q) {
3216
- const sub = args[0]?.toLowerCase();
3217
- switch (sub) {
3218
- case 'save': {
3219
- const name = args[1];
3220
- if (!name)
3221
- return { text: '用法: /ws save <name>' };
3222
- const cwd = this.sessionStore.getCwd(ctx.userId);
3223
- if (!cwd)
3224
- return { text: '请先 /cd 设置工作目录' };
3225
- this.workspaceStore.save(name, cwd);
3226
- return { text: `已保存 workspace "${name}" -> ${cwd}` };
3227
- }
3228
- case 'use': {
3229
- const name = args[1];
3230
- if (!name)
3231
- return { text: '用法: /ws use <name>' };
3232
- const wsPath = this.workspaceStore.get(name);
3233
- if (!wsPath)
3234
- return { text: `workspace "${name}" 不存在` };
3235
- if (!fs.existsSync(wsPath))
3236
- return { text: `路径不存在: ${wsPath}` };
3237
- // 目录校验与 /cd、ls.switch 对齐:目录被删后同路径出现同名文件(或
3238
- // workspace.json 被手改)时 realpath 照样成功——放行会让下一次 run
3239
- // 以文件为 cwd spawn(ENOTDIR)。
3240
- if (!isDirectorySafe(wsPath))
3241
- return { text: `不是目录: ${wsPath}` };
3242
- // Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
3243
- // TOCTOU guard: mirror cmdCd — if the path vanishes
3244
- // between existsSync and realpath, reply gracefully instead of
3245
- // throwing into the queue task's silent .catch.
3246
- let canonical;
3247
- try {
3248
- canonical = fs.realpathSync(wsPath);
3249
- }
3250
- catch {
3251
- return { text: `路径无效: ${wsPath}` };
3252
- }
3253
- // Record usage timestamp AFTER successful path validation
3254
- this.workspaceStore.touch(name);
3255
- // Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
3256
- return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
3257
- }
3258
- case 'remove': {
3259
- const name = args[1];
3260
- if (!name)
3261
- return { text: '用法: /ws remove <name>' };
3262
- if (!this.workspaceStore.has(name))
3263
- return { text: `workspace "${name}" 不存在` };
3264
- this.workspaceStore.remove(name);
3265
- return { text: `已删除 workspace "${name}"` };
3266
- }
3267
- case 'list':
3268
- default: {
3269
- const filter = q?.trim() || undefined;
3270
- const entries = this.workspaceStore.list();
3271
- const currentCwd = this.sessionStore.getCwd(ctx.userId);
3272
- const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
3273
- // Sort entries by the current preference (view concern, not stored)
3274
- if (sortMode === 'recent') {
3275
- // Most recently used first; same lastUsedAt → alphabetical by name (deterministic)
3276
- entries.sort((a, b) => {
3277
- if (b.lastUsedAt !== a.lastUsedAt)
3278
- return b.lastUsedAt - a.lastUsedAt;
3279
- return a.name.localeCompare(b.name);
3280
- });
3281
- }
3282
- else {
3283
- // Alphabetical by alias name
3284
- entries.sort((a, b) => a.name.localeCompare(b.name));
3285
- }
3286
- // 关键词筛选:alias name 或完整 path 命中即保留,大小写不敏感的子串包含
3287
- // (禁止 new RegExp(q)——用户搜 `foo(` 会直接抛)。插在排序之后、分页
3288
- // 之前:先分页再过滤会出现某页只剩 1 条的残页,且翻页时筛选像是丢了。
3289
- const ql = filter?.toLowerCase();
3290
- const visible = ql
3291
- ? entries.filter((e) => e.name.toLowerCase().includes(ql) || e.path.toLowerCase().includes(ql))
3292
- : entries;
3293
- // Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
3294
- // offsets so prev/next always step by WS_PAGE_SIZE.
3295
- const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(visible, offset, WS_PAGE_SIZE);
3296
- // Build body elements: current cwd + cwd list with dividers
3297
- const bodyElements = [];
3298
- // Current working directory section
3299
- bodyElements.push({
3300
- tag: 'div',
3301
- text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
3302
- });
3303
- bodyElements.push({ tag: 'hr' });
3304
- // 搜索行:「当前工作目录」之下、排序行之上(与 /ls 同一视觉动线:主信息
3305
- // 在上、过滤紧随其后、列表在下)
3306
- bodyElements.push(searchBar({
3307
- cmd: 'ws.filter',
3308
- placeholder: '搜索名称或路径,输完回车',
3309
- currentQuery: filter,
3310
- }));
3311
- if (filter) {
3312
- bodyElements.push({
3313
- tag: 'div',
3314
- text: {
3315
- tag: 'lark_md',
3316
- content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 个 workspace(点「清除筛选」取消)`,
3317
- },
3318
- });
3319
- }
3320
- if (entries.length === 0 && !filter) {
3321
- bodyElements.push({
3322
- tag: 'div',
3323
- text: { tag: 'lark_md', content: '没有保存的 workspace' },
3324
- });
3325
- }
3326
- else if (filter && visible.length === 0) {
3327
- bodyElements.push({
3328
- tag: 'div',
3329
- text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
3330
- });
3331
- }
3332
- else {
3333
- // Sort mode indicator + toggle button: placed above the list so the user
3334
- // sees "current mode" and "switch to X" before scanning entries.
3335
- // On narrow (mobile) screens, the label and button must convey both
3336
- // what-is-active and what-clicking-does to avoid ambiguity.
3337
- const currentLabel = sortMode === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
3338
- const switchLabel = sortMode === 'recent' ? '🔤 字母顺序' : '🕐 最近使用';
3339
- bodyElements.push({ tag: 'hr' });
3340
- bodyElements.push({
3341
- tag: 'column_set',
3342
- columns: [
3343
- {
3344
- tag: 'column',
3345
- width: 'weighted',
3346
- weight: 1,
3347
- vertical_align: 'center',
3348
- elements: [
3349
- {
3350
- tag: 'div',
3351
- text: { tag: 'lark_md', content: `排序:${currentLabel}` },
3352
- },
3353
- ],
3354
- },
3355
- {
3356
- tag: 'column',
3357
- width: 'auto',
3358
- elements: [
3359
- cardButton(`切换为 ${switchLabel}`, { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) }, { type: 'default', size: 'small' }),
3360
- ],
3361
- },
3362
- ],
3363
- });
3364
- bodyElements.push({ tag: 'hr' });
3365
- for (const entry of pageEntries) {
3366
- bodyElements.push({
3367
- tag: 'div',
3368
- text: { tag: 'lark_md', content: `**${entry.name}** → \`${entry.path}\`` },
3369
- });
3370
- // Use column_set+column for 2.0 (action tag not supported in 2.0)
3371
- bodyElements.push({
3372
- tag: 'column_set',
3373
- columns: [
3374
- {
3375
- tag: 'column',
3376
- width: 'auto',
3377
- elements: [
3378
- cardButton('切换', {
3379
- cmd: 'ws.use',
3380
- name: entry.name,
3381
- offset: safeOffset,
3382
- ...(filter ? { q: filter } : {}),
3383
- }, { type: 'primary', size: 'small' }),
3384
- ],
3385
- },
3386
- {
3387
- tag: 'column',
3388
- width: 'auto',
3389
- elements: [
3390
- cardButton('删除', {
3391
- cmd: 'ws.remove',
3392
- name: entry.name,
3393
- offset: safeOffset,
3394
- ...(filter ? { q: filter } : {}),
3395
- }, { type: 'danger', size: 'small' }),
3396
- ],
3397
- },
3398
- ],
3399
- });
3400
- bodyElements.push({ tag: 'hr' });
3401
- }
3402
- bodyElements.pop();
3403
- // Pagination bar — separate row below the list (not crowded with sort
3404
- // toggle; mobile-friendly vertical layout).
3405
- if (hasPagination) {
3406
- const bar = paginationBar({
3407
- cmd: 'ws.page',
3408
- offset: safeOffset,
3409
- pageSize: WS_PAGE_SIZE,
3410
- total: totalCount,
3411
- extra: filter ? { q: filter } : {},
3412
- label: `**${currentPage}/${totalPages}**(${totalCount})`,
3413
- prevText: '⬅',
3414
- nextText: '➡',
3415
- });
3416
- bodyElements.push({ tag: 'hr' });
3417
- bodyElements.push(...bar);
3418
- }
3419
- }
3420
- const card = {
3421
- schema: '2.0',
3422
- config: { wide_screen_mode: true, update_multi: true },
3423
- header: { title: { tag: 'plain_text', content: 'Workspaces' } },
3424
- body: { elements: bodyElements },
3425
- };
3426
- return { card };
3427
- }
3428
- }
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
+ });
3429
2814
  }
3430
2815
  /**
3431
2816
  * DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
@@ -3454,286 +2839,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3454
2839
  return { sessionPreset, currentPreset };
3455
2840
  }
3456
2841
  cmdResume(args, ctx, offset = 0) {
3457
- const entry = this.sessionStore.get(ctx.userId);
3458
- const cwd = entry?.cwd;
3459
- // Valid agent kinds for the /resume [agent] [N] feature
3460
- const VALID_AGENTS = CommandRouter.VALID_AGENTS;
3461
- // Parse arguments: /resume [agent] [N] or /resume [sessionId] or /resume [agent] [sessionId]
3462
- let agentKind = this.config.defaultAgent;
3463
- let limit;
3464
- let sessionIdArg;
3465
- if (args[0]) {
3466
- if (VALID_AGENTS.includes(args[0])) {
3467
- agentKind = args[0];
3468
- if (args[1]) {
3469
- if (/^\d+$/.test(args[1])) {
3470
- limit = parseInt(args[1], 10);
3471
- }
3472
- else {
3473
- sessionIdArg = args[1];
3474
- }
3475
- }
3476
- }
3477
- else if (/^\d+$/.test(args[0])) {
3478
- limit = parseInt(args[0], 10);
3479
- }
3480
- else {
3481
- sessionIdArg = args[0];
3482
- }
3483
- }
3484
- // `/resume <sessionId>` — manually set session id (needs an existing cwd to bind to)
3485
- if (sessionIdArg) {
3486
- if (!entry || !cwd)
3487
- return { text: '请先 /cd 设置工作目录' };
3488
- const reader = agentKind !== this.config.defaultAgent
3489
- ? this.sessionReaderRegistry.get(agentKind)
3490
- : this.sessionReader;
3491
- const verifyContent = reader.readSessionContent(sessionIdArg, cwd);
3492
- // If session doesn't exist, reject the write
3493
- if (verifyContent.events.length === 0 &&
3494
- !verifyContent.usage &&
3495
- !verifyContent.aiTitle &&
3496
- !verifyContent.recap &&
3497
- !verifyContent.displayTitle) {
3498
- return {
3499
- text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
3500
- };
3501
- }
3502
- // DSH preset 一致性校验(§6 风险 1):session 的 preset 在创建时固定,
3503
- // 若与当前配置不符,直接复用会让后续 run 报 agent-preset-conflict。
3504
- // 不静默复用——明确提示用户,由用户决定是否继续(配置里手动改/清 preset)。
3505
- if (agentKind === 'dsh' && this.config.defaultAgent === 'dsh') {
3506
- const presetMismatch = this.dshPresetMismatch(reader, sessionIdArg, cwd);
3507
- if (presetMismatch) {
3508
- return {
3509
- text: `⚠️ 该 DSH session 的 preset 是「${presetMismatch.sessionPreset}」,与当前配置「${presetMismatch.currentPreset}」不一致。\n` +
3510
- `preset 在 session 创建时固定,无法中途切换(DSH 返回 agent-preset-conflict)。\n` +
3511
- `继续恢复将在下次 run 失败。请在 /config 中把预设模式改回「${presetMismatch.sessionPreset}」后重试,或保持当前配置并发新消息新建会话。`,
3512
- };
3513
- }
3514
- }
3515
- // 校验通过后才写入
3516
- this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
3517
- const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
3518
- agentKind,
3519
- maxEvents: AUTO_RESUME_MAX_EVENTS,
3520
- });
3521
- // Action buttons: stop (active) + compact (capable runner, not running) + new session
3522
- const actions = this.buildResumeActionButtons({
3523
- isActive,
3524
- activeRunRunId,
3525
- cwd,
3526
- compact: this.canCompactSession(agentKind, cwd)
3527
- ? { sessionId: sessionIdArg, agentKind }
3528
- : undefined,
3529
- newSession: true,
3530
- });
3531
- const card = buildSessionHistoryCard({
3532
- sessionId: sessionIdArg,
3533
- cwd,
3534
- displayTitle,
3535
- aiTitle,
3536
- recap,
3537
- events: content,
3538
- usage,
3539
- }, {
3540
- agentKind,
3541
- headerText: `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`,
3542
- title: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
3543
- usageResult: 'success',
3544
- emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
3545
- actions,
3546
- });
3547
- return { card };
3548
- }
3549
- // `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
3550
- // Use agentKind to select the appropriate sessionReader
3551
- if (!cwd)
3552
- return { text: '请先 /cd 设置工作目录' };
3553
- const pageSize = clampInt(limit ?? RESUME_PAGE_SIZE, 1, RESUME_PAGE_SIZE);
3554
- let reader;
3555
- // Use this.sessionReader when agentKind matches default, otherwise get from registry
3556
- if (agentKind === this.config.defaultAgent) {
3557
- reader = this.sessionReader;
3558
- }
3559
- else {
3560
- reader = this.sessionReaderRegistry.get(agentKind);
3561
- }
3562
- // Fetch the requested page. If the offset is stale (sessions added/removed
3563
- // since the page was rendered), clamp it to the last valid page start and
3564
- // re-fetch so the card never shows a misaligned/empty page.
3565
- let pageOffset = offset;
3566
- let pageResult;
3567
- try {
3568
- pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
3569
- // 末页起点对齐页边界((总页数-1)*pageSize,如 25 条/页 20 → offset 20),
3570
- // 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
3571
- const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
3572
- if (pageOffset < 0 || pageOffset > maxOffset) {
3573
- pageOffset = clampInt(pageOffset, 0, maxOffset);
3574
- pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
3575
- }
3576
- }
3577
- catch (err) {
3578
- // 读取失败必须给用户可见反馈,而不是误导性的「没有 session 记录」
3579
- const msg = err instanceof Error ? err.message : String(err);
3580
- return { text: `读取 ${agentDisplayName(agentKind)} session 列表失败: ${msg}` };
3581
- }
3582
- const { sessions: allSessions, total } = pageResult;
3583
- if (allSessions.length === 0) {
3584
- const agentName = agentDisplayName(agentKind);
3585
- const hint = agentKind === this.config.defaultAgent
3586
- ? `\n提示:可使用 /resume <agent> 切换查看其他 Agent 的 session`
3587
- : '';
3588
- return { text: `当前目录没有 ${agentName} session 记录\n${cwd}${hint}` };
3589
- }
3590
- // Pre-fetch aiTitle/recap/displayTitle only for the first few rows —
3591
- // full reads are expensive (whole-file JSONL scan). Remaining rows fall
3592
- // back to the lightweight summary already returned by listSessions.
3593
- const sessionMeta = new Map();
3594
- const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
3595
- for (const s of allSessions.slice(0, prefetchCount)) {
3596
- // 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
3597
- // DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
3598
- // `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
3599
- // 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
3600
- // `session/list` 就已经给全了。
3601
- if (s.title || s.lastUserMessage || s.lastAssistantMessage)
3602
- continue;
3603
- // 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
3604
- // readSessionSummary,避免对前 N 条各做一次全量 JSONL 解析来构造
3605
- // 立即被丢弃的 events 数组。其他 agent 无此方法时回退全量读取再取字段。
3606
- const meta = reader.readSessionSummary
3607
- ? reader.readSessionSummary(s.sessionId, cwd)
3608
- : (() => {
3609
- const c = reader.readSessionContent(s.sessionId, cwd);
3610
- return { aiTitle: c.aiTitle, recap: c.recap, displayTitle: c.displayTitle };
3611
- })();
3612
- sessionMeta.set(s.sessionId, {
3613
- aiTitle: meta.aiTitle,
3614
- recap: meta.recap,
3615
- displayTitle: meta.displayTitle,
3616
- });
3617
- }
3618
- const now = Date.now();
3619
- function formatRelativeTime(mtimeMs) {
3620
- const diffSec = Math.floor((now - mtimeMs) / 1000);
3621
- if (diffSec < 60)
3622
- return '刚刚';
3623
- if (diffSec < 3600)
3624
- return `${Math.floor(diffSec / 60)} 分钟前`;
3625
- if (diffSec < 86400)
3626
- return `${Math.floor(diffSec / 3600)} 小时前`;
3627
- if (diffSec < 604800)
3628
- return `${Math.floor(diffSec / 86400)} 天前`;
3629
- return new Date(mtimeMs).toLocaleDateString('zh-CN');
3630
- }
3631
- const elements = [
3632
- {
3633
- tag: 'div',
3634
- text: { tag: 'lark_md', content: `📂 当前工作目录:\`${cwd}\`` },
3635
- },
3636
- ];
3637
- // Get current sessionId for the agent being listed
3638
- const currentId = entry?.sessions?.get(agentKind) ?? '';
3639
- for (const s of allSessions) {
3640
- // Only mark as current if we're listing the defaultAgent's sessions
3641
- const isCurrent = agentKind === this.config.defaultAgent && s.sessionId === currentId;
3642
- const timeStr = formatRelativeTime(s.mtime);
3643
- const buttonText = isCurrent ? '✓ 当前会话' : '恢复此会话';
3644
- const meta = sessionMeta.get(s.sessionId);
3645
- // Title priority:
3646
- // 1. 行级 title(服务端投影,DSH)→ 「标题」。
3647
- // 2. Prefetched displayTitle (aiTitle or last user message) keeps its
3648
- // original label (AI 标题 / 最近输入).
3649
- // 3. Otherwise the lightweight summary renders under the neutral
3650
- // "会话摘要" label — summary is the first user message, not the
3651
- // last input, so "最近输入" would be semantically wrong.
3652
- // 4. Placeholder summaries (no user message) render no title section
3653
- // at all — never leak '(无摘要)'/'(no user message)'/'New Session'.
3654
- const summaryIsPlaceholder = RESUME_SUMMARY_PLACEHOLDERS.has(s.summary.trim());
3655
- const prefetchedTitle = meta?.displayTitle;
3656
- // 行级 title 也过占位符过滤:DSH 对空会话可能给 "New Session",旧路径
3657
- // 的 summary 会被挡掉,新路径如果不过滤就会把它当真实标题渲染。
3658
- const rowTitleRaw = s.title?.trim() ?? '';
3659
- const rowTitle = rowTitleRaw && !RESUME_SUMMARY_PLACEHOLDERS.has(rowTitleRaw) ? rowTitleRaw : undefined;
3660
- // aiTitle 不能单独作为"预取标题"键:若 displayTitle 缺失(理论上 opencode
3661
- // info.title 非空但 export 无 user text),标题兜底是 summary,label 必须
3662
- // 是中性"会话摘要"而不是"最近输入"(Review 边缘分支)。
3663
- const titleText = prefetchedTitle ?? rowTitle ?? (summaryIsPlaceholder ? '' : s.summary);
3664
- const titleLabel = prefetchedTitle
3665
- ? meta?.aiTitle
3666
- ? 'AI 标题'
3667
- : '最近输入'
3668
- : rowTitle
3669
- ? '标题'
3670
- : summaryIsPlaceholder
3671
- ? ''
3672
- : '会话摘要';
3673
- const sections = [];
3674
- if (titleText) {
3675
- sections.push(`🏷️ **${titleLabel}**\n${titleText.slice(0, 80)}${titleText.length > 80 ? '...' : ''}`);
3676
- }
3677
- if (meta?.recap) {
3678
- sections.push(`📝 **Recap**\n${meta.recap.slice(0, 60)}${meta.recap.length > 60 ? '...' : ''}`);
3679
- }
3680
- // 行级预览(DSH 服务端投影):最后一句我说了什么 / 最后一句它答了什么。
3681
- // 只对提供了字段的 reader 渲染,其他 agent 的卡片一个字都不变。
3682
- if (s.lastUserMessage) {
3683
- sections.push(`✏️ **最近输入**\n${sessionPreviewLine(s.lastUserMessage, RESUME_PREVIEW_MAX)}`);
3684
- }
3685
- if (s.lastAssistantMessage) {
3686
- sections.push(`📤 **最后输出**\n${sessionPreviewLine(s.lastAssistantMessage, RESUME_PREVIEW_MAX)}`);
3687
- }
3688
- const infoStr = sections.length > 0 ? '\n' + sections.join('\n──\n') : '';
3689
- elements.push({ tag: 'hr' });
3690
- // Both current and non-current sessions get a button for visual consistency
3691
- elements.push({
3692
- tag: 'div',
3693
- text: {
3694
- tag: 'lark_md',
3695
- content: `**${s.sessionId}**${infoStr}\n⏱ ${timeStr}${isCurrent ? ' •' : ''}`,
3696
- },
3697
- });
3698
- elements.push({
3699
- tag: 'column_set',
3700
- columns: [
3701
- {
3702
- tag: 'column',
3703
- width: 'auto',
3704
- elements: [
3705
- cardButton(buttonText, { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind }, { type: isCurrent ? 'default' : 'primary', disabled: isCurrent }),
3706
- ],
3707
- },
3708
- ],
3709
- });
3710
- }
3711
- // Pagination bar (only shown when there are more sessions than pageSize)
3712
- if (total > pageSize) {
3713
- const { totalPages, currentPage } = pageInfo(total, pageOffset, pageSize);
3714
- const bar = paginationBar({
3715
- cmd: 'resume.page',
3716
- offset: pageOffset,
3717
- pageSize,
3718
- total,
3719
- extra: { agent: agentKind, pageSize },
3720
- label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
3721
- });
3722
- elements.push({ tag: 'hr' });
3723
- elements.push(...bar);
3724
- }
3725
- const card = {
3726
- schema: '2.0',
3727
- config: { wide_screen_mode: true },
3728
- header: {
3729
- title: {
3730
- tag: 'plain_text',
3731
- content: `🔁 恢复历史会话 · ${agentDisplayName(agentKind)}`,
3732
- },
3733
- },
3734
- body: { elements },
3735
- };
3736
- return { card };
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
+ });
3737
2854
  }
3738
2855
  cmdActive(_args, _ctx, offset = 0) {
3739
2856
  // List all active runs from lark-remote memory (not from file system scan)
@@ -3957,216 +3074,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3957
3074
  // 使用共享渲染模块构建卡片(Agent 选择器作为普通 select 字段参与渲染)
3958
3075
  return { card: buildConfigCardFromTabs(tabs, displayConfig) };
3959
3076
  }
3960
- /** /order 实现(public:测试直接调用,替代 as unknown as)。 */
3961
- cmdOrder(args, _ctx, offset = 0) {
3962
- const sub = args[0]?.toLowerCase();
3963
- // /order — list orders (CardKit 2.0 with pagination)
3964
- if (!sub || sub === 'list') {
3965
- this.orderStore.reload();
3966
- const allOrders = this.orderStore.get();
3967
- const elements = [];
3968
- if (allOrders.length === 0) {
3969
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '暂无指令' } });
3970
- }
3971
- else {
3972
- // Pagination calculations
3973
- const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageOrders, hasPagination, } = pageSlice(allOrders, offset, ORDER_PAGE_SIZE);
3974
- for (let i = 0; i < pageOrders.length; i++) {
3975
- const order = pageOrders[i];
3976
- const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
3977
- // 文字在上方(有别名时用 `$name` 前缀标识触发词)
3978
- elements.push({
3979
- tag: 'div',
3980
- text: {
3981
- tag: 'lark_md',
3982
- content: order.alias ? `\`$${order.alias}\` ${displayText}` : displayText,
3983
- },
3984
- });
3985
- // 操作行:左侧别名按钮(无别名显示 +别名,有别名点击可改),
3986
- // 右侧「删除指令」按钮。两列均 weighted 1:1,跨行对齐。
3987
- const leftColumn = order.alias
3988
- ? [
3989
- cardButton(`$${order.alias}`, { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
3990
- cardButton('✕', {
3991
- cmd: 'order.aliasRemove',
3992
- orderId: order.id,
3993
- aliasName: order.alias,
3994
- offset: safeOffset,
3995
- }, { type: 'default', size: 'small' }),
3996
- ]
3997
- : [
3998
- cardButton('+别名', { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
3999
- ];
4000
- elements.push({
4001
- tag: 'column_set',
4002
- columns: [
4003
- {
4004
- tag: 'column',
4005
- width: 'weighted',
4006
- weight: 1,
4007
- elements: leftColumn,
4008
- },
4009
- {
4010
- tag: 'column',
4011
- width: 'weighted',
4012
- weight: 1,
4013
- vertical_align: 'center',
4014
- elements: [
4015
- {
4016
- tag: 'column_set',
4017
- columns: [
4018
- {
4019
- tag: 'column',
4020
- width: 'auto',
4021
- elements: [
4022
- cardButton('▶ 执行', { cmd: 'order.exec', orderId: order.id }, { type: 'primary', size: 'small' }),
4023
- ],
4024
- },
4025
- {
4026
- // 编辑:弹蓝色 header 输入卡(CardKit 2.0 input,回车提交),
4027
- // 镜像 order.aliasEdit 流程。order.textEdit 是 control-only action
4028
- // (isImmediateAction 注册),不 spawn agent。
4029
- tag: 'column',
4030
- width: 'auto',
4031
- elements: [
4032
- cardButton('编辑', { cmd: 'order.textEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
4033
- ],
4034
- },
4035
- {
4036
- tag: 'column',
4037
- width: 'auto',
4038
- elements: [
4039
- cardButton('删除', { cmd: 'order.delete', orderId: order.id, offset: safeOffset }, { type: 'danger', size: 'small' }),
4040
- ],
4041
- },
4042
- ],
4043
- },
4044
- ],
4045
- },
4046
- ],
4047
- });
4048
- // 非最后一项,添加分隔线
4049
- if (i < pageOrders.length - 1) {
4050
- elements.push({ tag: 'hr' });
4051
- }
4052
- }
4053
- // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
4054
- if (hasPagination) {
4055
- const bar = paginationBar({
4056
- cmd: 'order.page',
4057
- offset: safeOffset,
4058
- pageSize: ORDER_PAGE_SIZE,
4059
- total: totalCount,
4060
- label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
4061
- });
4062
- elements.push({ tag: 'hr' });
4063
- elements.push(...bar);
4064
- }
4065
- }
4066
- return {
4067
- card: {
4068
- schema: '2.0',
4069
- config: { wide_screen_mode: true, update_multi: true },
4070
- header: { title: { tag: 'plain_text', content: '📋 指令' }, template: 'turquoise' },
4071
- body: { elements },
4072
- },
4073
- };
4074
- }
4075
- // /order alias — CLI 别名管理(卡片是主入口,命令是备选):
4076
- // /order alias add <orderId|N> <name> — 给某条指令绑定别名
4077
- // /order alias rm <orderId|N> — 移除某条指令的别名
4078
- // /order alias list — 列出已绑定的别名(并入列表卡片)
4079
- if (sub === 'alias') {
4080
- const op = args[1];
4081
- if (op === 'add') {
4082
- const target = args[2];
4083
- const name = args[3];
4084
- if (!target || !name) {
4085
- return { text: '用法: /order alias add <orderId|序号> <别名名>' };
4086
- }
4087
- return this.cmdOrderAliasAdd(target, name);
4088
- }
4089
- if (op === 'rm') {
4090
- const target = args[2];
4091
- if (!target) {
4092
- return { text: '用法: /order alias rm <orderId|序号>' };
4093
- }
4094
- return this.cmdOrderAliasRemove(target);
4095
- }
4096
- if (!op || op === 'list') {
4097
- // 别名管理列表并入列表卡片
4098
- return this.cmdOrder([], _ctx, offset);
4099
- }
4100
- return {
4101
- text: '用法: /order alias [add <orderId|序号> <别名名> | rm <orderId|序号> | list]',
4102
- };
4103
- }
4104
- // /order save <text>
4105
- if (sub === 'save') {
4106
- const text = args.slice(1).join(' ');
4107
- if (!text) {
4108
- return { text: '用法: /order save <指令文本>' };
4109
- }
4110
- try {
4111
- this.orderStore.save(text);
4112
- return { text: `✅ 已保存指令: ${text.slice(0, 50)}${text.length > 50 ? '...' : ''}` };
4113
- }
4114
- catch (err) {
4115
- return { text: `保存失败: ${err.message}` };
4116
- }
4117
- }
4118
- // /order edit <orderId|序号> <新文本> — CLI 备选路径(卡片是主入口)。
4119
- // 与 /order save / alias add|rm 对称;复用 resolveOrderTarget。
4120
- // 与卡片路径(handleOrderTextInput)对齐 trim 语义,避免「卡片 trim、CLI 不 trim」
4121
- // 两条入口对同一文本存出不同结果。
4122
- if (sub === 'edit') {
4123
- const target = args[1];
4124
- const newText = args.slice(2).join(' ').trim();
4125
- if (!target || !newText) {
4126
- return { text: '用法: /order edit <orderId|序号> <新文本>' };
4127
- }
4128
- const order = this.resolveOrderTarget(target);
4129
- if (!order)
4130
- return { text: `指令不存在: ${target}` };
4131
- try {
4132
- this.orderStore.updateText(order.id, newText);
4133
- const preview = newText.length > 50 ? newText.slice(0, 50) + '...' : newText;
4134
- return { text: `✅ 已更新指令: ${preview}` };
4135
- }
4136
- catch (err) {
4137
- return { text: `保存失败: ${err.message}` };
4138
- }
4139
- }
4140
- return {
4141
- text: '用法: /order [list|save <text>|edit <orderId|序号> <新文本>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
4142
- };
4143
- }
4144
- /** /order alias add:按 orderId 或列表序号(1 基)解析目标指令并绑定别名。 */
4145
- cmdOrderAliasAdd(target, name) {
4146
- const order = this.resolveOrderTarget(target);
4147
- if (!order)
4148
- return { text: `指令不存在: ${target}` };
4149
- try {
4150
- this.orderStore.setAlias(order.id, name);
4151
- return { text: `✅ 已给指令绑定别名 $${name}` };
4152
- }
4153
- catch (err) {
4154
- return { text: `保存失败: ${err.message}` };
4155
- }
4156
- }
4157
- /** /order alias rm:按 orderId 或列表序号(1 基)移除指令的别名。 */
4158
- cmdOrderAliasRemove(target) {
4159
- const order = this.resolveOrderTarget(target);
4160
- if (!order)
4161
- return { text: `指令不存在: ${target}` };
4162
- if (!order.alias)
4163
- return { text: '该指令没有别名' };
4164
- // 先读别名名再解绑:setAlias(undefined) 会 delete 同一对象引用上的 alias 字段。
4165
- const aliasName = order.alias;
4166
- this.orderStore.setAlias(order.id, undefined);
4167
- return { text: `✅ 已移除别名 $${aliasName}` };
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
+ });
4168
3087
  }
4169
- /** 将 `/order alias add|rm` 的目标解析为 order:优先 orderId 精确匹配,回退列表序号(1 基)。 */
4170
3088
  resolveOrderTarget(target) {
4171
3089
  const all = this.orderStore.get();
4172
3090
  const byId = all.find((o) => o.id === target);