lark-remote 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.en.md +2 -6
  3. package/README.md +3 -7
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/index.d.ts +0 -7
  7. package/dist/bridge/index.js +21 -63
  8. package/dist/bridge/queue-manager.js +4 -3
  9. package/dist/card/approval-render.js +39 -94
  10. package/dist/card/bash-renderer.js +12 -21
  11. package/dist/card/card-shared.d.ts +13 -1
  12. package/dist/card/card-shared.js +21 -30
  13. package/dist/card/run-renderer.d.ts +0 -22
  14. package/dist/card/run-renderer.js +206 -153
  15. package/dist/card/run-state.d.ts +29 -1
  16. package/dist/card/run-state.js +172 -84
  17. package/dist/card/text-truncate.d.ts +0 -9
  18. package/dist/card/text-truncate.js +0 -9
  19. package/dist/card/tool-render.d.ts +4 -1
  20. package/dist/card/tool-render.js +76 -32
  21. package/dist/cli.js +2 -2
  22. package/dist/common/fs.d.ts +14 -0
  23. package/dist/common/fs.js +57 -3
  24. package/dist/config/codex-config.js +2 -0
  25. package/dist/config/index.d.ts +0 -5
  26. package/dist/config/index.js +0 -11
  27. package/dist/config/kimi-config.js +1 -0
  28. package/dist/config/opencode-config.js +13 -2
  29. package/dist/config/qr.d.ts +36 -0
  30. package/dist/config/qr.js +124 -0
  31. package/dist/config/wizard.js +69 -27
  32. package/dist/connector/index.d.ts +3 -2
  33. package/dist/connector/index.js +5 -3
  34. package/dist/index.js +14 -41
  35. package/dist/instance-lock.js +7 -0
  36. package/dist/persistence/atomic-write.d.ts +2 -0
  37. package/dist/persistence/atomic-write.js +20 -5
  38. package/dist/persistence/load-json-file.d.ts +1 -2
  39. package/dist/persistence/load-json-file.js +2 -3
  40. package/dist/platform/agent-stopper.d.ts +22 -0
  41. package/dist/platform/agent-stopper.js +19 -0
  42. package/dist/platform/command.d.ts +11 -0
  43. package/dist/platform/command.js +107 -0
  44. package/dist/platform/identity.d.ts +33 -0
  45. package/dist/platform/identity.js +187 -0
  46. package/dist/platform/path.d.ts +24 -0
  47. package/dist/platform/path.js +110 -0
  48. package/dist/platform/select.d.ts +9 -0
  49. package/dist/platform/select.js +12 -0
  50. package/dist/platform/shell.d.ts +51 -0
  51. package/dist/platform/shell.js +75 -0
  52. package/dist/platform/spawn.d.ts +41 -0
  53. package/dist/platform/spawn.js +77 -0
  54. package/dist/platform/terminator-posix.d.ts +38 -0
  55. package/dist/platform/terminator-posix.js +103 -0
  56. package/dist/platform/terminator-win32.d.ts +21 -0
  57. package/dist/platform/terminator-win32.js +110 -0
  58. package/dist/platform/terminator.d.ts +44 -0
  59. package/dist/platform/terminator.js +24 -0
  60. package/dist/platform/types.d.ts +23 -0
  61. package/dist/platform/types.js +5 -0
  62. package/dist/restart.js +4 -2
  63. package/dist/router/card-helpers.d.ts +33 -6
  64. package/dist/router/card-helpers.js +67 -11
  65. package/dist/router/config/common/render.d.ts +1 -1
  66. package/dist/router/config/common/render.js +1 -1
  67. package/dist/router/config-diff.d.ts +4 -0
  68. package/dist/router/config-diff.js +70 -0
  69. package/dist/router/index.d.ts +66 -6
  70. package/dist/router/index.js +450 -315
  71. package/dist/router/utils.d.ts +6 -0
  72. package/dist/router/utils.js +12 -0
  73. package/dist/runner/bash/runner.d.ts +9 -2
  74. package/dist/runner/bash/runner.js +45 -20
  75. package/dist/runner/claude/session.d.ts +4 -1
  76. package/dist/runner/claude/session.js +18 -4
  77. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  78. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  79. package/dist/runner/codex/app-server/translator.js +146 -0
  80. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  81. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  82. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  83. package/dist/runner/common/acp/base-translator.js +271 -0
  84. package/dist/runner/common/acp/protocol-helpers.d.ts +39 -0
  85. package/dist/runner/common/acp/protocol-helpers.js +34 -0
  86. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  87. package/dist/runner/common/jsonrpc/transport.js +25 -5
  88. package/dist/runner/common/process-stopper.d.ts +4 -26
  89. package/dist/runner/common/process-stopper.js +4 -76
  90. package/dist/runner/common/runner-utils.d.ts +0 -5
  91. package/dist/runner/common/runner-utils.js +0 -7
  92. package/dist/runner/common/spawning-runner.d.ts +42 -132
  93. package/dist/runner/common/spawning-runner.js +52 -271
  94. package/dist/runner/common/usage.d.ts +2 -0
  95. package/dist/runner/common/usage.js +2 -0
  96. package/dist/runner/index.d.ts +1 -1
  97. package/dist/runner/kimi/acp/runner.d.ts +45 -42
  98. package/dist/runner/kimi/acp/runner.js +140 -293
  99. package/dist/runner/kimi/acp/translator.d.ts +18 -100
  100. package/dist/runner/kimi/acp/translator.js +56 -242
  101. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  102. package/dist/runner/opencode/acp/runner.js +73 -256
  103. package/dist/runner/opencode/acp/translator.d.ts +28 -103
  104. package/dist/runner/opencode/acp/translator.js +51 -236
  105. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  106. package/dist/runner/pi/rpc/translator.js +47 -9
  107. package/dist/runner/probe.d.ts +8 -11
  108. package/dist/runner/probe.js +17 -21
  109. package/dist/runner/registry.d.ts +4 -4
  110. package/dist/runner/registry.js +4 -4
  111. package/dist/runner/types.d.ts +38 -1
  112. package/dist/session/claude/sessions.js +34 -57
  113. package/dist/session/codex/rollout-reader.d.ts +10 -0
  114. package/dist/session/codex/rollout-reader.js +123 -41
  115. package/dist/session/common/constants.d.ts +7 -0
  116. package/dist/session/common/constants.js +9 -0
  117. package/dist/session/common/two-pass.d.ts +22 -0
  118. package/dist/session/common/two-pass.js +70 -0
  119. package/dist/session/dsh/sessions.js +6 -5
  120. package/dist/session/kimi/sessions.d.ts +6 -0
  121. package/dist/session/kimi/sessions.js +50 -73
  122. package/dist/session/opencode/sessions.js +26 -8
  123. package/dist/session/pi/sessions.js +54 -73
  124. package/dist/update/index.d.ts +1 -2
  125. package/dist/update/index.js +1 -2
  126. package/dist/update/install.d.ts +2 -1
  127. package/dist/update/install.js +58 -9
  128. package/dist/update/version-check.d.ts +5 -0
  129. package/dist/update/version-check.js +9 -0
  130. package/package.json +9 -6
  131. package/dist/update/startup-hint.d.ts +0 -5
  132. package/dist/update/startup-hint.js +0 -11
@@ -2,14 +2,15 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, walkNestedContainer, getAgentConfig, } from '../config/index.js';
5
+ import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, getAgentConfig, } from '../config/index.js';
6
6
  import { syncAgentChoices } from '../runner/index.js';
7
7
  import { WorkspaceStore } from '../workspace/index.js';
8
8
  import { OrderStore } from '../order/index.js';
9
9
  import { resolveAlias } from '../order/alias-resolve.js';
10
+ import { diffConfig, setNestedValue } from './config-diff.js';
10
11
  import { getLogger } from '../logger/index.js';
11
12
  import { activeRunUsage, clampInt } from './utils.js';
12
- import { markdownDiv, buildSessionHistoryCard, paginationBar } from './card-helpers.js';
13
+ import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
13
14
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
14
15
  import { atomicWrite } from '../persistence/atomic-write.js';
15
16
  import { checkLatestVersion as defaultCheckLatestVersion, isNewer as defaultIsNewer, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
@@ -48,6 +49,16 @@ function pageSlice(items, offset, pageSize) {
48
49
  };
49
50
  }
50
51
  const ACTIVE_PAGE_SIZE = 20;
52
+ /** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
53
+ function formatBytes(bytes) {
54
+ if (bytes < 1024)
55
+ return `${bytes}B`;
56
+ if (bytes < 1024 * 1024)
57
+ return `${(bytes / 1024).toFixed(1)}KB`;
58
+ if (bytes < 1024 * 1024 * 1024)
59
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
60
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
61
+ }
51
62
  /**
52
63
  * /order 列表页大小;指令超过此数量时显示分页导航栏。
53
64
  * 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
@@ -68,6 +79,9 @@ const WS_PAGE_SIZE = 5;
68
79
  * 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
69
80
  * 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
70
81
  *
82
+ * 「跳转页码」输入框(paginationBar 内置)的提交值优先于 offset;非法输入
83
+ * 返回 `error` 文案,调用方应回错误 toast 且不刷新卡片。
84
+ *
71
85
  * resume.page / active.page 共用,避免两处重复实现分页归一化。
72
86
  */
73
87
  function normalizePageArgs(value, maxPageSize) {
@@ -75,6 +89,13 @@ function normalizePageArgs(value, maxPageSize) {
75
89
  const pageSize = Number.isFinite(rawPageSize)
76
90
  ? clampInt(Math.trunc(rawPageSize), 1, maxPageSize)
77
91
  : maxPageSize;
92
+ const jump = parseJumpOffset(value.inputValue, pageSize);
93
+ if (jump === null) {
94
+ return { pageSize, alignedOffset: 0, error: PAGE_JUMP_INVALID_HINT };
95
+ }
96
+ if (jump !== undefined) {
97
+ return { pageSize, alignedOffset: Math.floor(jump / pageSize) * pageSize };
98
+ }
78
99
  const rawOffset = Number(value.offset);
79
100
  const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
80
101
  return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
@@ -88,6 +109,7 @@ const RESUME_CONTENT_PREFETCH = 5;
88
109
  */
89
110
  const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
90
111
  import { newSessionButton, resumeCompactButton, agentDisplayName } from '../card/card-shared.js';
112
+ import { displayName } from '../platform/path.js';
91
113
  /**
92
114
  * Card-action commands that must run with enqueue({ immediate: true }).
93
115
  * These bypass the serial queue and execute immediately.
@@ -114,46 +136,8 @@ export function isImmediateAction(cmd) {
114
136
  // help.* wildcard: any command starting with "help."
115
137
  if (cmd.startsWith('help.'))
116
138
  return true;
117
- return (cmd === 'new-session' ||
118
- cmd === 'stop' ||
119
- cmd === 'ls.file' ||
120
- cmd === 'ls.refresh' ||
121
- cmd === 'ls.browse' ||
122
- cmd === 'ls.switch' ||
123
- cmd === 'ls.page' ||
124
- cmd === 'resume.page' || // control operation: paginate only, never spawns claude
125
- cmd === 'active.page' || // control operation: paginate active card
126
- cmd === 'ws.page' || // control operation: paginate /ws list only
127
- cmd === 'ws.remove' ||
128
- cmd === 'ws.sort' || // control operation: toggle sort mode only
129
- cmd === 'resume.use' ||
130
- cmd === 'ws.use' ||
131
- cmd === 'queue.immediate' ||
132
- cmd === 'queue.cancel' ||
133
- cmd === 'queue.diagnose' ||
134
- cmd === 'queue.edit' ||
135
- cmd === 'queue.input' ||
136
- cmd === 'order.delete' ||
137
- cmd === 'order.page' ||
138
- cmd === 'order.aliasEdit' ||
139
- cmd === 'order.aliasInput' ||
140
- cmd === 'order.aliasRemove' ||
141
- cmd === 'order.textEdit' ||
142
- cmd === 'order.textInput' ||
143
- cmd === 'config.toggle' ||
144
- cmd === 'config.set' ||
145
- cmd === 'config.input' ||
146
- cmd === 'config.save' ||
147
- // 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
148
- // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
149
- // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
150
- cmd === 'approval.respond' ||
151
- cmd === 'approval.toggle' ||
152
- cmd === 'approval.answer' ||
153
- cmd === 'approval.answerSubmit' ||
154
- cmd === 'approval.answerCustom' ||
155
- cmd === 'approval.answerNote' ||
156
- cmd === 'approval.planFeedback');
139
+ // 名单在 APPROVAL_ACTION_SPECS 之后定义(模块初始化完成后才可能被调用)。
140
+ return IMMEDIATE_ACTION_CMDS.has(cmd);
157
141
  }
158
142
  const QUESTION_INDEX_PRESENT = (v) => v.questionIndex !== undefined;
159
143
  const APPROVAL_ACTION_SPECS = {
@@ -256,6 +240,68 @@ const APPROVAL_ACTION_SPECS = {
256
240
  failureToast: (msg) => ({ toast: { type: 'error', content: `修改意见保存失败:${msg}` } }),
257
241
  },
258
242
  };
243
+ // ===========================================================================
244
+ // W2.1 命令名单单一事实源
245
+ // 直返语义(cardAction 同步返回 toast 给飞书回调,index.ts 控制层据此不经
246
+ // 队列直接处理)与即时语义(绕串行队列 enqueueImmediate,§9.6)正交,两个
247
+ // 独立清单只共享审批命令名来源(APPROVAL_ACTION_SPECS keys)——历史上同一
248
+ // 份知识四份拷贝曾两次漂移(order.textInput 漏 immediate、answer 家族漏直返)。
249
+ // ===========================================================================
250
+ /** 审批命令名清单(APPROVAL_ACTION_SPECS keys,handleCardAction 分发共用)。 */
251
+ export const APPROVAL_ACTION_CMDS = Object.keys(APPROVAL_ACTION_SPECS);
252
+ /**
253
+ * 直返命令:点击后同步返回 toast 给飞书回调,不经队列。
254
+ * index.ts 控制层直返清单的唯一来源(勿在别处复制名单)。
255
+ */
256
+ export const DIRECT_RETURN_CMDS = new Set([
257
+ 'queue.input',
258
+ 'order.aliasInput',
259
+ 'order.aliasRemove',
260
+ 'order.textInput',
261
+ 'config.save',
262
+ ...APPROVAL_ACTION_CMDS,
263
+ ]);
264
+ /** 即时命令清单(绕串行队列 enqueueImmediate)。 */
265
+ const IMMEDIATE_ACTION_CMDS = new Set([
266
+ 'new-session',
267
+ 'stop',
268
+ 'ls.file',
269
+ 'ls.refresh',
270
+ 'ls.browse',
271
+ 'ls.switch',
272
+ 'ls.page', // control operation: paginate only, never spawns claude
273
+ 'resume.page', // control operation: paginate only, never spawns claude
274
+ 'active.page', // control operation: paginate active card
275
+ 'ws.page', // control operation: paginate /ws list only
276
+ 'ws.remove',
277
+ 'ws.sort', // control operation: toggle sort mode only
278
+ 'resume.use',
279
+ 'ws.use',
280
+ 'queue.immediate',
281
+ 'queue.cancel',
282
+ 'queue.diagnose',
283
+ 'queue.edit',
284
+ 'queue.input',
285
+ 'order.delete',
286
+ 'order.page',
287
+ 'order.aliasEdit',
288
+ 'order.aliasInput',
289
+ 'order.aliasRemove',
290
+ 'order.textEdit',
291
+ 'order.textInput',
292
+ 'config.toggle',
293
+ 'config.set',
294
+ 'config.input',
295
+ 'config.save',
296
+ // 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
297
+ // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
298
+ // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
299
+ ...APPROVAL_ACTION_CMDS,
300
+ ]);
301
+ /** W2.8 单源:payload.offset → 钳位 offset(原先 9 处逐字副本)。 */
302
+ function payloadOffset(value) {
303
+ return Math.max(0, Math.trunc(Number(value.offset) || 0));
304
+ }
259
305
  /** 卡片 cardAction payload 缺字段的统一报错文案(原先 12 处手写且已漂移出两种前缀)。 */
260
306
  const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
261
307
  export class CommandRouter {
@@ -401,6 +447,11 @@ export class CommandRouter {
401
447
  * Validates payload and dispatches to the corresponding command.
402
448
  */
403
449
  async handleCardAction(value, ctx) {
450
+ // W2.1:审批家族以 APPROVAL_ACTION_SPECS keys 为单一来源分发
451
+ // (原 7 个 case 与 spec keys 是同一份知识的两份拷贝,已两次漂移)。
452
+ if (APPROVAL_ACTION_CMDS.includes(value.cmd)) {
453
+ return this.handleApprovalAction(value, ctx);
454
+ }
404
455
  switch (value.cmd) {
405
456
  case 'ls.file':
406
457
  await this.cardLsFile(value.path, ctx);
@@ -508,14 +559,6 @@ export class CommandRouter {
508
559
  // 调 updateCardInPlace → Feishu API 乱序到达导致 toggle 卡死。
509
560
  // 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
510
561
  return this.enqueueConfigAction(value, ctx);
511
- case 'approval.respond':
512
- case 'approval.toggle':
513
- case 'approval.answer':
514
- case 'approval.answerSubmit':
515
- case 'approval.answerCustom':
516
- case 'approval.answerNote':
517
- case 'approval.planFeedback':
518
- return this.handleApprovalAction(value, ctx);
519
562
  case 'codex.compact':
520
563
  await this.bridge.handleCodexCompact(value, ctx);
521
564
  return;
@@ -955,6 +998,21 @@ export class CommandRouter {
955
998
  card: { type: 'raw', data: card },
956
999
  };
957
1000
  }
1001
+ /**
1002
+ * 分页卡动作的 offset 解析:优先读取「跳转页码」输入框(paginationBar 内置
1003
+ * 的 CardKit 2.0 input),未输入则回退 `payload.offset`(上一页/下一页按钮)。
1004
+ * 非法页码返回 `{ error }`,调用方应回错误 toast 且不刷新卡片。
1005
+ */
1006
+ pagingOffset(value, defaultPageSize) {
1007
+ const rawSize = Number(value.pageSize);
1008
+ const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? Math.trunc(rawSize) : defaultPageSize;
1009
+ const jump = parseJumpOffset(value.inputValue, pageSize);
1010
+ if (jump === null)
1011
+ return { error: PAGE_JUMP_INVALID_HINT };
1012
+ if (jump !== undefined)
1013
+ return { offset: jump };
1014
+ return { offset: payloadOffset(value) };
1015
+ }
958
1016
  /**
959
1017
  * Handle ls.switch: switch cwd to target directory after path validation.
960
1018
  */
@@ -988,8 +1046,10 @@ export class CommandRouter {
988
1046
  }
989
1047
  // Set cwd + auto-resume + user notification (shared with /cd and /ws use)
990
1048
  const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
991
- // Card channel: refresh ls card in-place (existing behavior preserved)
992
- const card = this.cmdLs([canonical], ctx, 0);
1049
+ // Card channel: refresh ls card in-place (existing behavior preserved)。
1050
+ // value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
1051
+ // 「返回」仍回到用户最初 /ls 的那个目录。
1052
+ const card = this.cmdLs([canonical], ctx, 0, value.root);
993
1053
  await this.bridge.updateCardInPlace(card.card, ctx);
994
1054
  // Message channel: send switch confirmation / auto-resume card to chat
995
1055
  await this.bridge.sendResult(notifyResult, ctx);
@@ -1009,11 +1069,11 @@ export class CommandRouter {
1009
1069
  try {
1010
1070
  card =
1011
1071
  targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
1012
- ? this.cmdLs([targetPath], ctx, offset)
1013
- : this.cmdLs([], ctx, offset);
1072
+ ? this.cmdLs([targetPath], ctx, offset, value.root)
1073
+ : this.cmdLs([], ctx, offset, value.root);
1014
1074
  }
1015
1075
  catch {
1016
- card = this.cmdLs([], ctx, offset);
1076
+ card = this.cmdLs([], ctx, offset, value.root);
1017
1077
  }
1018
1078
  await this.bridge.updateCardInPlace(card.card, ctx);
1019
1079
  }
@@ -1022,10 +1082,13 @@ export class CommandRouter {
1022
1082
  */
1023
1083
  async handleLsPage(value, ctx) {
1024
1084
  const targetPath = value.path;
1025
- const offset = value.offset ?? 0;
1026
1085
  if (!targetPath) {
1027
1086
  return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
1028
1087
  }
1088
+ const paging = this.pagingOffset(value, CommandRouter.LS_PAGE_SIZE);
1089
+ if ('error' in paging) {
1090
+ return { toast: { type: 'error', content: paging.error } };
1091
+ }
1029
1092
  const resolvedTarget = path.resolve(targetPath);
1030
1093
  // TOCTOU guard (review P2-2): existsSync → statSync can race; without the
1031
1094
  // try/catch the throw lands in enqueueImmediate's silent .catch and the
@@ -1040,7 +1103,7 @@ export class CommandRouter {
1040
1103
  if (!isDir) {
1041
1104
  return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
1042
1105
  }
1043
- const card = this.cmdLs([resolvedTarget], ctx, offset);
1106
+ const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root);
1044
1107
  // 直接更新卡片,与 ls.browse/ls.refresh 行为一致
1045
1108
  await this.bridge.updateCardInPlace(card.card, ctx);
1046
1109
  return { toast: { type: 'success', content: '' } };
@@ -1055,7 +1118,10 @@ export class CommandRouter {
1055
1118
  const resolvedAgent = validAgents.includes(resumeAgent)
1056
1119
  ? resumeAgent
1057
1120
  : this.config.defaultAgent;
1058
- const { pageSize, alignedOffset } = normalizePageArgs(value, RESUME_PAGE_SIZE);
1121
+ const { pageSize, alignedOffset, error } = normalizePageArgs(value, RESUME_PAGE_SIZE);
1122
+ if (error) {
1123
+ return { toast: { type: 'error', content: error } };
1124
+ }
1059
1125
  const entry = this.sessionStore.get(ctx.userId);
1060
1126
  const cwd = entry?.cwd;
1061
1127
  if (!cwd) {
@@ -1082,7 +1148,10 @@ export class CommandRouter {
1082
1148
  * Mirrors handleResumePage — updates the same card, never sends a new one.
1083
1149
  */
1084
1150
  async handleActivePage(value, ctx) {
1085
- const { alignedOffset } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
1151
+ const { alignedOffset, error } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
1152
+ if (error) {
1153
+ return { toast: { type: 'error', content: error } };
1154
+ }
1086
1155
  const result = this.cmdActive([], ctx, alignedOffset);
1087
1156
  if (!result.card) {
1088
1157
  return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
@@ -1105,7 +1174,7 @@ export class CommandRouter {
1105
1174
  return;
1106
1175
  }
1107
1176
  // Always reset to page 0 when browsing to a new directory
1108
- const card = this.cmdLs([resolvedTarget], ctx, 0);
1177
+ const card = this.cmdLs([resolvedTarget], ctx, 0, value.root);
1109
1178
  await this.bridge.updateCardInPlace(card.card, ctx);
1110
1179
  }
1111
1180
  /**
@@ -1159,6 +1228,51 @@ export class CommandRouter {
1159
1228
  internalKey: `order-${order.id}-${this.orderExecKeyCounter++}`,
1160
1229
  };
1161
1230
  }
1231
+ /**
1232
+ * W2.8 单源:重建 order/ws 列表卡(不投递,card 进 callback 响应体用)。
1233
+ */
1234
+ rebuildListCard(kind, offset, ctx) {
1235
+ return kind === 'order'
1236
+ ? this.cmdOrder([], ctx, offset).card
1237
+ : this.cmdWs([], ctx, offset).card;
1238
+ }
1239
+ /**
1240
+ * W2.8 单源:「重建列表卡 → updateCardInPlace」原地刷新骨架(列表卡删除/
1241
+ * 翻页/移除/排序动作共用)。返回重建后的卡片;无卡时返回 undefined 且跳过更新。
1242
+ */
1243
+ async refreshListCard(kind, value, ctx) {
1244
+ const card = this.rebuildListCard(kind, payloadOffset(value), ctx);
1245
+ if (!card)
1246
+ return undefined;
1247
+ await this.bridge.updateCardInPlace(card, ctx);
1248
+ return card;
1249
+ }
1250
+ /**
1251
+ * W2.8 单源:order 三个 input handler(aliasInput/aliasRemove/textInput)
1252
+ * 的公共骨架——校验 orderId → reload+find → mutate → 成功后 toast + raw 卡。
1253
+ * mutate 返回 error 时仅回 error toast,不重渲染。
1254
+ * 注意:本 helper 只服务「toast + card 进响应体」语义(飞书需用响应体原地
1255
+ * 替换 pre-click 编辑卡);updateCardInPlace 原地刷新走 refreshListCard。
1256
+ */
1257
+ async mutateOrderAndRefreshCard(value, ctx, opts, mutate) {
1258
+ if (!value.orderId) {
1259
+ return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1260
+ }
1261
+ this.orderStore.reload();
1262
+ const order = this.orderStore.get().find((o) => o.id === value.orderId);
1263
+ if (!order) {
1264
+ return { toast: { type: 'error', content: opts.missingToast } };
1265
+ }
1266
+ const outcome = mutate(order);
1267
+ if (!outcome.ok) {
1268
+ return { toast: { type: 'error', content: outcome.error } };
1269
+ }
1270
+ const result = this.cmdOrder([], ctx, payloadOffset(value));
1271
+ return {
1272
+ toast: { type: 'success', content: outcome.toast },
1273
+ card: { type: 'raw', data: result.card },
1274
+ };
1275
+ }
1162
1276
  /**
1163
1277
  * Handle order.delete: remove the order and update card in place.
1164
1278
  */
@@ -1173,9 +1287,7 @@ export class CommandRouter {
1173
1287
  // Delete the order
1174
1288
  this.orderStore.remove(orderId);
1175
1289
  // Refresh the order list and update card in place, preserving current page
1176
- const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1177
- const result = this.cmdOrder([], ctx, currentOffset);
1178
- await this.bridge.updateCardInPlace(result.card, ctx);
1290
+ await this.refreshListCard('order', value, ctx);
1179
1291
  return { toast: { type: 'success', content: '已删除指令' } };
1180
1292
  }
1181
1293
  /**
@@ -1183,10 +1295,12 @@ export class CommandRouter {
1183
1295
  * Mirrors handleLsPage — updates the same card, never sends a new one.
1184
1296
  */
1185
1297
  async handleOrderPage(value, ctx) {
1186
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1298
+ const paging = this.pagingOffset(value, ORDER_PAGE_SIZE);
1299
+ if ('error' in paging) {
1300
+ return { toast: { type: 'error', content: paging.error } };
1301
+ }
1187
1302
  // cmdOrder internally clamps stale/out-of-range offsets
1188
- const result = this.cmdOrder([], ctx, offset);
1189
- await this.bridge.updateCardInPlace(result.card, ctx);
1303
+ await this.refreshListCard('order', { offset: paging.offset }, ctx);
1190
1304
  return { toast: { type: 'success', content: '' } };
1191
1305
  }
1192
1306
  /**
@@ -1199,7 +1313,7 @@ export class CommandRouter {
1199
1313
  */
1200
1314
  async showOrderEditCard(value, ctx, opts) {
1201
1315
  const orderId = value.orderId;
1202
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1316
+ const offset = payloadOffset(value);
1203
1317
  if (!orderId) {
1204
1318
  return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1205
1319
  }
@@ -1277,62 +1391,36 @@ export class CommandRouter {
1277
1391
  * 留空 = 解绑;非空按命名规则 + 全局唯一校验,失败仅 toast 不重渲染。
1278
1392
  */
1279
1393
  async handleOrderAliasInput(value, ctx) {
1280
- const orderId = value.orderId;
1281
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1282
1394
  const name = value.inputValue ?? value.formValue?.['aliasName'];
1283
- if (!orderId) {
1284
- return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1285
- }
1286
- this.orderStore.reload();
1287
- const order = this.orderStore.get().find((o) => o.id === orderId);
1288
- if (!order) {
1289
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1290
- }
1291
- const trimmed = name?.trim() ?? '';
1292
- // 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
1293
- // 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
1294
- const prevAlias = order.alias;
1295
- try {
1296
- this.orderStore.setAlias(order.id, trimmed === '' ? undefined : trimmed);
1297
- }
1298
- catch (err) {
1299
- return { toast: { type: 'error', content: err.message } };
1300
- }
1301
- // 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
1302
- // 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
1303
- // 点击前那张卡),见 handleQueueInput 同款注释。
1304
- const result = this.cmdOrder([], ctx, offset);
1305
- return {
1306
- toast: {
1307
- type: 'success',
1308
- content: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
1309
- },
1310
- card: { type: 'raw', data: result.card },
1311
- };
1395
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
1396
+ const trimmed = name?.trim() ?? '';
1397
+ // 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
1398
+ // 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
1399
+ const prevAlias = order.alias;
1400
+ try {
1401
+ this.orderStore.setAlias(order.id, trimmed === '' ? undefined : trimmed);
1402
+ }
1403
+ catch (err) {
1404
+ return { ok: false, error: err.message };
1405
+ }
1406
+ return {
1407
+ ok: true,
1408
+ toast: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
1409
+ };
1410
+ });
1312
1411
  }
1313
1412
  /** Handle order.aliasRemove: 删除某条指令的别名并原地重渲染列表卡。 */
1314
1413
  async handleOrderAliasRemove(value, ctx) {
1315
- const orderId = value.orderId;
1316
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1317
- if (!orderId) {
1318
- return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1319
- }
1320
- this.orderStore.reload();
1321
- const order = this.orderStore.get().find((o) => o.id === orderId);
1322
- if (!order || !order.alias) {
1323
- return { toast: { type: 'error', content: '指令不存在或没有别名' } };
1324
- }
1325
- // 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
1326
- // 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
1327
- const removedAlias = order.alias;
1328
- this.orderStore.setAlias(order.id, undefined);
1329
- // 必须把 card 放进 callback 响应体让飞书原地替换 pre-click 卡片,否则删除
1330
- // 后卡片停留在旧状态(别名视觉上未消失)。同 handleQueueInput 语义。
1331
- const result = this.cmdOrder([], ctx, offset);
1332
- return {
1333
- toast: { type: 'success', content: `✅ 已移除别名 $${removedAlias}` },
1334
- card: { type: 'raw', data: result.card },
1335
- };
1414
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或没有别名' }, (order) => {
1415
+ if (!order.alias) {
1416
+ return { ok: false, error: '指令不存在或没有别名' };
1417
+ }
1418
+ // 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
1419
+ // 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
1420
+ const removedAlias = order.alias;
1421
+ this.orderStore.setAlias(order.id, undefined);
1422
+ return { ok: true, toast: `✅ 已移除别名 $${removedAlias}` };
1423
+ });
1336
1424
  }
1337
1425
  /**
1338
1426
  * Handle order.textEdit: 展示指令文本编辑卡(input + ✓ 提交图标)。
@@ -1365,37 +1453,22 @@ export class CommandRouter {
1365
1453
  * 保留 usedAt / alias / createdAt(OrderStore.updateText 只动 text 字段)。
1366
1454
  */
1367
1455
  async handleOrderTextInput(value, ctx) {
1368
- const orderId = value.orderId;
1369
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1370
1456
  const raw = value.inputValue ?? value.formValue?.['text'];
1371
- if (!orderId) {
1372
- return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1373
- }
1374
- this.orderStore.reload();
1375
- const order = this.orderStore.get().find((o) => o.id === orderId);
1376
- if (!order) {
1377
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1378
- }
1379
- let updated;
1380
- try {
1381
- updated = this.orderStore.updateText(order.id, raw ?? '');
1382
- }
1383
- catch (err) {
1384
- return { toast: { type: 'error', content: err.message } };
1385
- }
1386
- if (!updated) {
1387
- // reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
1388
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1389
- }
1390
- // 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
1391
- // 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
1392
- // 点击前那张卡),见 handleQueueInput / handleOrderAliasInput 同款注释。
1393
- const result = this.cmdOrder([], ctx, offset);
1394
- const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
1395
- return {
1396
- toast: { type: 'success', content: `✅ 已更新指令: ${preview}` },
1397
- card: { type: 'raw', data: result.card },
1398
- };
1457
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
1458
+ let updated;
1459
+ try {
1460
+ updated = this.orderStore.updateText(order.id, raw ?? '');
1461
+ }
1462
+ catch (err) {
1463
+ return { ok: false, error: err.message };
1464
+ }
1465
+ if (!updated) {
1466
+ // reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
1467
+ return { ok: false, error: '指令不存在或已被删除' };
1468
+ }
1469
+ const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
1470
+ return { ok: true, toast: `✅ 已更新指令: ${preview}` };
1471
+ });
1399
1472
  }
1400
1473
  /**
1401
1474
  * Handle ws.use card action: switch to the workspace, then refresh the /ws
@@ -1427,11 +1500,7 @@ export class CommandRouter {
1427
1500
  }
1428
1501
  }
1429
1502
  // Refresh the /ws list card in place so "recent" sort is immediately visible
1430
- const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1431
- const refreshed = this.cmdWs([], ctx, currentOffset);
1432
- if (refreshed.card) {
1433
- await this.bridge.updateCardInPlace(refreshed.card, ctx);
1434
- }
1503
+ await this.refreshListCard('ws', value, ctx);
1435
1504
  // Toast feedback (suppress text when a persistent message was already sent)
1436
1505
  return {
1437
1506
  toast: {
@@ -1455,9 +1524,7 @@ export class CommandRouter {
1455
1524
  this.cmdWs(['remove', name], ctx);
1456
1525
  // Rebuild the /ws list card and update it in place, preserving the page
1457
1526
  // the user was on when they clicked 删除.
1458
- const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1459
- const refreshed = this.cmdWs([], ctx, currentOffset);
1460
- await this.bridge.updateCardInPlace(refreshed.card, ctx);
1527
+ await this.refreshListCard('ws', value, ctx);
1461
1528
  return { toast: { type: 'success', content: `已删除 workspace "${name}"` } };
1462
1529
  }
1463
1530
  /**
@@ -1465,10 +1532,12 @@ export class CommandRouter {
1465
1532
  * Mirrors handleOrderPage — updates the same card, never sends a new one.
1466
1533
  */
1467
1534
  async handleWsPage(value, ctx) {
1468
- const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1535
+ const paging = this.pagingOffset(value, WS_PAGE_SIZE);
1536
+ if ('error' in paging) {
1537
+ return { toast: { type: 'error', content: paging.error } };
1538
+ }
1469
1539
  // cmdWs internally clamps stale/out-of-range offsets
1470
- const result = this.cmdWs([], ctx, offset);
1471
- await this.bridge.updateCardInPlace(result.card, ctx);
1540
+ await this.refreshListCard('ws', { offset: paging.offset }, ctx);
1472
1541
  return { toast: { type: 'success', content: '' } };
1473
1542
  }
1474
1543
  /**
@@ -1480,8 +1549,7 @@ export class CommandRouter {
1480
1549
  const current = this.wsSortPreference.get(userId) ?? 'recent';
1481
1550
  const next = current === 'recent' ? 'alpha' : 'recent';
1482
1551
  this.wsSortPreference.set(userId, next);
1483
- const result = this.cmdWs([], ctx, 0);
1484
- await this.bridge.updateCardInPlace(result.card, ctx);
1552
+ await this.refreshListCard('ws', { offset: 0 }, ctx);
1485
1553
  const nextLabel = next === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
1486
1554
  return {
1487
1555
  toast: { type: 'success', content: `已切换为 ${nextLabel}` },
@@ -1495,26 +1563,42 @@ export class CommandRouter {
1495
1563
  await this.bridge.sendResult({ text: '卡片 payload 缺少 path' }, ctx);
1496
1564
  return;
1497
1565
  }
1498
- const resolvedTarget = path.resolve(target);
1499
- if (!fs.existsSync(resolvedTarget)) {
1500
- await this.bridge.sendResult({ text: `文件不存在: ${resolvedTarget}` }, ctx);
1501
- return;
1566
+ const result = await this.deliverFileToFeishu(path.resolve(target), ctx);
1567
+ if (result)
1568
+ await this.bridge.sendResult(result, ctx);
1569
+ }
1570
+ /**
1571
+ * 校验并发送本地文件到飞书(≤30MB)。成功返回 null(文件已发出),失败返回
1572
+ * 错误 CommandResult 由调用方展示。
1573
+ *
1574
+ * `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
1575
+ */
1576
+ async deliverFileToFeishu(resolved, ctx) {
1577
+ if (!fs.existsSync(resolved)) {
1578
+ return { text: `文件不存在: ${resolved}` };
1502
1579
  }
1503
- // Check it's a file, not directory
1504
- if (!fs.statSync(resolvedTarget).isFile()) {
1505
- await this.bridge.sendResult({ text: `不是文件: ${resolvedTarget}` }, ctx);
1506
- return;
1580
+ let stat;
1581
+ try {
1582
+ stat = fs.statSync(resolved);
1583
+ }
1584
+ catch {
1585
+ return { text: `无法读取: ${resolved}` };
1586
+ }
1587
+ if (stat.isDirectory()) {
1588
+ return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
1507
1589
  }
1508
- // Check file size (30MB, aligned with Feishu im/v1/files API)
1509
- const stat = fs.statSync(resolvedTarget);
1590
+ if (!stat.isFile()) {
1591
+ return { text: `不是普通文件: ${resolved}` };
1592
+ }
1593
+ // 30MB,与飞书 im/v1/files API 对齐
1510
1594
  if (stat.size > MAX_FILE_UPLOAD_SIZE) {
1511
1595
  const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
1512
- await this.bridge.sendResult({
1596
+ return {
1513
1597
  text: `文件太大 (${sizeMB}MB),超过 ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB 限制不能发送`,
1514
- }, ctx);
1515
- return;
1598
+ };
1516
1599
  }
1517
- await this.bridge.sendFile(resolvedTarget, ctx);
1600
+ await this.bridge.sendFile(resolved, ctx);
1601
+ return null;
1518
1602
  }
1519
1603
  // --- PendingConfig 辅助方法 ---
1520
1604
  /** 确保 pendingConfig 已初始化(从当前 config 克隆) */
@@ -1553,6 +1637,8 @@ export class CommandRouter {
1553
1637
  async dispatchConfigAction(value, ctx) {
1554
1638
  switch (value.cmd) {
1555
1639
  case 'config.toggle': {
1640
+ // 通用 boolean 翻转:目前无卡片字段使用(output.show* 已移除),
1641
+ // 保留供未来 boolean 配置项复用;configActionQueue 串行化覆盖连击。
1556
1642
  const key = value.key;
1557
1643
  if (!key) {
1558
1644
  await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
@@ -1682,68 +1768,10 @@ export class CommandRouter {
1682
1768
  /** 设置嵌套属性值(仅修改内存对象,不写盘) */
1683
1769
  /** 在 pendingConfig 上按 dot-separated key 设置嵌套值(config 卡片编辑用)。 */
1684
1770
  setNestedValue(target, key, value) {
1685
- // Path mapping 共用 config 模块的 mapAgentKey(G11 Inconsistency 修复):
1686
- // pi.xxx/codex.xxx/opencode.xxx → agents.xxx,claude.xxx 保持顶层
1687
- const mappedKey = mapAgentKey(key);
1688
- const parts = mappedKey.split('.');
1689
- // 复用 config 模块的 walkNestedContainer(含 __proto__/prototype/constructor
1690
- // 守卫与中间段补 {}),router 只保留 value=undefined → delete 分支。
1691
- const container = walkNestedContainer(target, parts, true);
1692
- const lastPart = parts[parts.length - 1];
1693
- assertSafeKeyPart(lastPart);
1694
- if (value === undefined) {
1695
- // value=undefined 表示"删除键"(如清空 reasoningEffort),
1696
- // 不能写成 undefined 值——diffConfig 会把 undefined 转成字面量 "undefined"
1697
- // 写入 config.yaml 并透传给 codex(ReasoningEffort::Custom("undefined"))。
1698
- delete container[lastPart];
1699
- }
1700
- else {
1701
- container[lastPart] = value;
1702
- }
1771
+ setNestedValue(target, key, value);
1703
1772
  }
1704
- /** 对比原始 config 与 pendingConfig,返回变化的 key→string 映射 */
1705
- /** config diff(public:测试直接调用,替代 as unknown as)。 */
1706
1773
  diffConfig(original, pending) {
1707
- const updates = {};
1708
- this.collectDiff('', original, pending, updates);
1709
- return updates;
1710
- }
1711
- /** 递归收集差异 */
1712
- collectDiff(prefix, original, pending, result) {
1713
- if (original === pending)
1714
- return;
1715
- // 如果 pending 是对象(非 null)但 original 不是对象,
1716
- // 把 original 当作空对象递归进入 pending 内部,
1717
- // 避免把整个对象转成 "[object Object]" 字符串
1718
- if (typeof pending === 'object' &&
1719
- pending !== null &&
1720
- (typeof original !== 'object' || original === null)) {
1721
- const pendObj = pending;
1722
- for (const k of Object.keys(pendObj)) {
1723
- const newKey = prefix ? `${prefix}.${k}` : k;
1724
- this.collectDiff(newKey, undefined, pendObj[k], result);
1725
- }
1726
- return;
1727
- }
1728
- if (typeof original !== 'object' ||
1729
- typeof pending !== 'object' ||
1730
- original === null ||
1731
- pending === null) {
1732
- // 叶子节点,记录差异
1733
- const key = prefix || 'root';
1734
- // pending 为 undefined 表示键被删除,必须保留 undefined 语义,
1735
- // 不能 String(pending) 成 "undefined"
1736
- result[key] = pending === undefined ? undefined : String(pending);
1737
- return;
1738
- }
1739
- // 两者都是对象,递归比较
1740
- const origObj = original;
1741
- const pendObj = pending;
1742
- const allKeys = new Set([...Object.keys(origObj), ...Object.keys(pendObj)]);
1743
- for (const k of allKeys) {
1744
- const newKey = prefix ? `${prefix}.${k}` : k;
1745
- this.collectDiff(newKey, origObj[k], pendObj[k], result);
1746
- }
1774
+ return diffConfig(original, pending);
1747
1775
  }
1748
1776
  /**
1749
1777
  * config 写盘后的运行时传播(P1-6:自 config.save 卡片路径抽出,卡片保存与
@@ -1925,6 +1953,9 @@ export class CommandRouter {
1925
1953
  return this.cmdCd(args, ctx);
1926
1954
  case 'ls':
1927
1955
  return this.cmdLs(args, ctx);
1956
+ case 'download':
1957
+ case 'd':
1958
+ return await this.cmdDownload(args, ctx);
1928
1959
  case 'ws':
1929
1960
  return this.cmdWs(args, ctx);
1930
1961
  case 'resume':
@@ -1969,7 +2000,16 @@ export class CommandRouter {
1969
2000
  // 文本组(需带参数,不宜用按钮;以纯文本行展示)
1970
2001
  const textCommands = [
1971
2002
  { cmd: 'cd', label: '/cd <path>', desc: '切换工作目录' },
1972
- { cmd: 'ls', label: '/ls [dir]', desc: '列出当前目录,可指定子目录' },
2003
+ {
2004
+ cmd: 'ls',
2005
+ label: '/ls [dir|file]',
2006
+ desc: '列出当前目录(可指定子目录);指定文件时列出该文件',
2007
+ },
2008
+ {
2009
+ cmd: 'download',
2010
+ label: '/download /d <path>',
2011
+ desc: '直接下载(发送)指定文件到飞书,上限 30MB',
2012
+ },
1973
2013
  { cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
1974
2014
  {
1975
2015
  cmd: 'order',
@@ -2469,8 +2509,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2469
2509
  return actions;
2470
2510
  }
2471
2511
  static LS_PAGE_SIZE = 30;
2472
- /** /ls 实现(public:测试直接调用,替代 as unknown as)。 */
2473
- cmdLs(args, ctx, offset = 0) {
2512
+ /**
2513
+ * `/ls` 实现(public:测试直接调用,替代 as unknown as)。
2514
+ *
2515
+ * @param args [] 列出 cwd;[path] 列出指定目录(或文件)
2516
+ * @param offset 分页起点(条目数)
2517
+ * @param rootDir 浏览起点:卡片「返回」按钮回到这里。省略时默认 = 本次列出的
2518
+ * 目录(`/ls <dir>` 的起点即该目录),因此「返回」不会把用户
2519
+ * 丢回 workspace cwd(用户明确反馈过)。
2520
+ */
2521
+ cmdLs(args, ctx, offset = 0, rootDir) {
2474
2522
  const cwd = this.sessionStore.getCwd(ctx.userId);
2475
2523
  if (!cwd) {
2476
2524
  return { text: '请先使用 /cd <path> 设置工作目录' };
@@ -2490,11 +2538,25 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2490
2538
  if (!fs.existsSync(potentialDir)) {
2491
2539
  return { text: `ls: ${args[0]}: No such file or directory` };
2492
2540
  }
2493
- if (!fs.statSync(potentialDir).isDirectory()) {
2494
- return { text: `ls: ${args[0]}: Not a directory` };
2541
+ let isDir;
2542
+ try {
2543
+ isDir = fs.statSync(potentialDir).isDirectory();
2544
+ }
2545
+ catch {
2546
+ // TOCTOU:existsSync 与 statSync 之间目标被删
2547
+ return { text: `ls: ${args[0]}: No such file or directory` };
2548
+ }
2549
+ if (!isDir) {
2550
+ // 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
2551
+ // 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
2552
+ return this.buildFileCard(potentialDir);
2495
2553
  }
2496
2554
  targetDir = potentialDir;
2497
2555
  }
2556
+ // 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
2557
+ // 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
2558
+ // 列出的目录,因此「返回」永远不会把用户丢回 workspace cwd。
2559
+ const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
2498
2560
  try {
2499
2561
  const entries = fs.readdirSync(targetDir, { withFileTypes: true });
2500
2562
  // Separate directories and files
@@ -2516,7 +2578,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2516
2578
  // Check if we need to show parent directory button
2517
2579
  const parentDir = path.dirname(targetDir);
2518
2580
  const hasParent = parentDir !== targetDir;
2581
+ // 「切换」相对 cwd(把工作目录切到当前浏览目录)
2519
2582
  const isSubdir = targetDir !== cwd;
2583
+ // 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
2584
+ const canReturn = targetDir !== browseRoot;
2520
2585
  // Build header with parent button
2521
2586
  const headerElements = [];
2522
2587
  if (hasParent) {
@@ -2525,17 +2590,21 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2525
2590
  text: { tag: 'plain_text', content: '上级' },
2526
2591
  type: 'default',
2527
2592
  size: 'small',
2528
- behaviors: [{ type: 'callback', value: { cmd: 'ls.browse', path: parentDir } }],
2593
+ behaviors: [
2594
+ { type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: browseRoot } },
2595
+ ],
2529
2596
  });
2530
2597
  }
2531
- // Show "返回" button when viewing a subdirectory
2532
- if (isSubdir) {
2598
+ // Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
2599
+ if (canReturn) {
2533
2600
  headerElements.push({
2534
2601
  tag: 'button',
2535
2602
  text: { tag: 'plain_text', content: '返回' },
2536
2603
  type: 'default',
2537
2604
  size: 'small',
2538
- behaviors: [{ type: 'callback', value: { cmd: 'ls.browse', path: cwd } }],
2605
+ behaviors: [
2606
+ { type: 'callback', value: { cmd: 'ls.browse', path: browseRoot, root: browseRoot } },
2607
+ ],
2539
2608
  });
2540
2609
  }
2541
2610
  headerElements.push({
@@ -2544,7 +2613,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2544
2613
  type: 'default',
2545
2614
  size: 'small',
2546
2615
  behaviors: [
2547
- { type: 'callback', value: { cmd: 'ls.refresh', path: targetDir, offset: safeOffset } },
2616
+ {
2617
+ type: 'callback',
2618
+ value: {
2619
+ cmd: 'ls.refresh',
2620
+ path: targetDir,
2621
+ offset: safeOffset,
2622
+ root: browseRoot,
2623
+ },
2624
+ },
2548
2625
  ],
2549
2626
  });
2550
2627
  // Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
@@ -2554,28 +2631,32 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2554
2631
  text: { tag: 'plain_text', content: '切换' },
2555
2632
  type: 'primary',
2556
2633
  size: 'small',
2557
- behaviors: [{ type: 'callback', value: { cmd: 'ls.switch', path: targetDir } }],
2634
+ // 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
2635
+ // 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
2636
+ behaviors: [
2637
+ { type: 'callback', value: { cmd: 'ls.switch', path: targetDir, root: browseRoot } },
2638
+ ],
2558
2639
  });
2559
2640
  }
2560
- const formatSize = (bytes) => {
2561
- if (bytes < 1024)
2562
- return `${bytes}B`;
2563
- if (bytes < 1024 * 1024)
2564
- return `${(bytes / 1024).toFixed(1)}KB`;
2565
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
2566
- };
2567
2641
  // Helper to create directory button - use targetDir
2568
2642
  const dirButton = (name) => ({
2569
2643
  tag: 'button',
2570
2644
  text: { tag: 'plain_text', content: `📁 ${name}` },
2571
2645
  type: 'default',
2572
2646
  behaviors: [
2573
- { type: 'callback', value: { cmd: 'ls.browse', path: path.join(targetDir, name) } },
2647
+ {
2648
+ type: 'callback',
2649
+ value: {
2650
+ cmd: 'ls.browse',
2651
+ path: path.join(targetDir, name),
2652
+ root: browseRoot,
2653
+ },
2654
+ },
2574
2655
  ],
2575
2656
  });
2576
2657
  // Helper to create file button with size - use targetDir
2577
2658
  const fileButton = (name, size) => {
2578
- const sizeStr = size !== undefined ? ` (${formatSize(size)})` : '';
2659
+ const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
2579
2660
  return {
2580
2661
  tag: 'button',
2581
2662
  text: { tag: 'plain_text', content: `📄 ${name}${sizeStr}` },
@@ -2589,10 +2670,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2589
2670
  // Build each section's elements (no tabs - use section headers)
2590
2671
  // Note: div+elements is not supported in CardKit 2.0, use column_set instead
2591
2672
  const elements = [];
2592
- // Status line showing directory contents count (with pagination info)
2593
- const status = hasPagination
2594
- ? `\n共 ${dirs.length} 目录, ${files.length} 文件 · 第 ${currentPage}/${totalPages} 页(共 ${totalCount} 项)`
2595
- : `\n共 ${dirs.length} 目录, ${files.length} 文件`;
2673
+ // Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
2674
+ // 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。
2675
+ const status = `\n共 ${dirs.length} 目录, ${files.length} 文件`;
2596
2676
  // Header info + navigation buttons - show targetDir in header
2597
2677
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
2598
2678
  elements.push({
@@ -2656,11 +2736,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2656
2736
  offset: safeOffset,
2657
2737
  pageSize: CommandRouter.LS_PAGE_SIZE,
2658
2738
  total: totalCount,
2659
- extra: { path: targetDir },
2739
+ extra: { path: targetDir, root: browseRoot },
2660
2740
  label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
2661
2741
  });
2662
2742
  elements.push({ tag: 'hr' });
2663
- elements.push(bar);
2743
+ elements.push(...bar);
2664
2744
  }
2665
2745
  return {
2666
2746
  card: {
@@ -2680,6 +2760,99 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2680
2760
  return { text: `读取目录失败: ${err.message}` };
2681
2761
  }
2682
2762
  }
2763
+ /**
2764
+ * `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
2765
+ *
2766
+ * 路径解析与 `/ls` 一致:`~` 展开到 home;相对路径基于当前 cwd(无 cwd 时
2767
+ * 必须给绝对路径)。成功时文件本身即回复,返回 null(不额外发文本)。
2768
+ */
2769
+ async cmdDownload(args, ctx) {
2770
+ const raw = args.join(' ').trim();
2771
+ if (raw === '') {
2772
+ return { text: '用法: /download <path>(别名 /d)' };
2773
+ }
2774
+ // 路径可能含空格:args 按空白切分后再拼回(未加引号也能还原原路径)
2775
+ let input = raw;
2776
+ if (input.startsWith('~')) {
2777
+ input = path.join(os.homedir(), input.slice(1));
2778
+ }
2779
+ if (!path.isAbsolute(input)) {
2780
+ const cwd = this.sessionStore.getCwd(ctx.userId);
2781
+ if (!cwd)
2782
+ return { text: '请先使用 /cd <path> 设置工作目录' };
2783
+ input = path.resolve(cwd, input);
2784
+ }
2785
+ return this.deliverFileToFeishu(path.resolve(input), ctx);
2786
+ }
2787
+ /**
2788
+ * 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
2789
+ *
2790
+ * `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
2791
+ * 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
2792
+ */
2793
+ buildFileCard(filePath) {
2794
+ let stat;
2795
+ try {
2796
+ stat = fs.statSync(filePath);
2797
+ }
2798
+ catch {
2799
+ stat = undefined;
2800
+ }
2801
+ const sizeText = stat ? formatBytes(stat.size) : '未知';
2802
+ const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
2803
+ const parentDir = path.dirname(filePath);
2804
+ const buttons = [
2805
+ {
2806
+ tag: 'button',
2807
+ text: { tag: 'plain_text', content: '📎 下载' },
2808
+ type: 'primary',
2809
+ size: 'small',
2810
+ behaviors: [{ type: 'callback', value: { cmd: 'ls.file', path: filePath } }],
2811
+ },
2812
+ ];
2813
+ if (parentDir !== filePath) {
2814
+ buttons.push({
2815
+ tag: 'button',
2816
+ text: { tag: 'plain_text', content: '上级' },
2817
+ type: 'default',
2818
+ size: 'small',
2819
+ // 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 workspace cwd
2820
+ behaviors: [
2821
+ { type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: parentDir } },
2822
+ ],
2823
+ });
2824
+ }
2825
+ return {
2826
+ card: {
2827
+ schema: '2.0',
2828
+ config: { wide_screen_mode: true, update_multi: true },
2829
+ header: {
2830
+ title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
2831
+ template: 'blue',
2832
+ },
2833
+ body: {
2834
+ elements: [
2835
+ {
2836
+ tag: 'div',
2837
+ text: {
2838
+ tag: 'lark_md',
2839
+ content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
2840
+ },
2841
+ },
2842
+ {
2843
+ tag: 'column_set',
2844
+ columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
2845
+ },
2846
+ { tag: 'hr' },
2847
+ {
2848
+ tag: 'div',
2849
+ text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
2850
+ },
2851
+ ],
2852
+ },
2853
+ },
2854
+ };
2855
+ }
2683
2856
  cmdWs(args, ctx, offset = 0) {
2684
2857
  const sub = args[0]?.toLowerCase();
2685
2858
  switch (sub) {
@@ -2864,7 +3037,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2864
3037
  nextText: '➡',
2865
3038
  });
2866
3039
  bodyElements.push({ tag: 'hr' });
2867
- bodyElements.push(bar);
3040
+ bodyElements.push(...bar);
2868
3041
  }
2869
3042
  }
2870
3043
  const card = {
@@ -3170,7 +3343,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3170
3343
  label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
3171
3344
  });
3172
3345
  elements.push({ tag: 'hr' });
3173
- elements.push(bar);
3346
+ elements.push(...bar);
3174
3347
  }
3175
3348
  const card = {
3176
3349
  schema: '2.0',
@@ -3209,16 +3382,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3209
3382
  const { totalPages, safeOffset, currentPage } = pageInfo(totalCount, offset, pageSize);
3210
3383
  // Slice: distribute offset across agent runs first, then bash runs
3211
3384
  const elements = [];
3212
- // Page info
3213
- if (totalPages > 1) {
3214
- elements.push({
3215
- tag: 'div',
3216
- text: {
3217
- tag: 'lark_md',
3218
- content: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
3219
- },
3220
- });
3221
- }
3222
3385
  let remaining = pageSize;
3223
3386
  let skipped = safeOffset;
3224
3387
  // Agent runs section
@@ -3231,7 +3394,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3231
3394
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
3232
3395
  elements.push({
3233
3396
  tag: 'div',
3234
- text: { tag: 'lark_md', content: `**📂 ${run.cwd.split('/').pop() ?? run.cwd}**` },
3397
+ text: { tag: 'lark_md', content: `**📂 ${displayName(run.cwd)}**` },
3235
3398
  });
3236
3399
  elements.push({
3237
3400
  tag: 'div',
@@ -3283,37 +3446,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3283
3446
  elements.push({ tag: 'hr' });
3284
3447
  }
3285
3448
  }
3286
- // Pagination buttons
3449
+ // Pagination bar(W2.9 复用 paginationBar:safeOffset 已对齐页边界,
3450
+ // offset±pageSize 与原先 currentPage±1 的页码换算等价)
3287
3451
  if (totalPages > 1) {
3288
- const paginationButtons = [];
3289
- if (currentPage > 1) {
3290
- const prevOffset = (currentPage - 2) * pageSize;
3291
- paginationButtons.push({
3292
- tag: 'button',
3293
- text: { tag: 'plain_text', content: '◀ 上一页' },
3294
- type: 'default',
3295
- size: 'small',
3296
- behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: prevOffset } }],
3297
- });
3298
- }
3299
- if (currentPage < totalPages) {
3300
- const nextOffset = currentPage * pageSize;
3301
- paginationButtons.push({
3302
- tag: 'button',
3303
- text: { tag: 'plain_text', content: '下一页 ▶' },
3304
- type: 'primary',
3305
- size: 'small',
3306
- behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: nextOffset } }],
3307
- });
3308
- }
3309
- elements.push({
3310
- tag: 'column_set',
3311
- columns: paginationButtons.map((btn) => ({
3312
- tag: 'column',
3313
- width: 'auto',
3314
- elements: [btn],
3315
- })),
3316
- });
3452
+ elements.push(...paginationBar({
3453
+ cmd: 'active.page',
3454
+ offset: safeOffset,
3455
+ pageSize,
3456
+ total: totalCount,
3457
+ label: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
3458
+ prevText: '◀ 上一页',
3459
+ nextText: '下一页 ▶',
3460
+ }));
3317
3461
  }
3318
3462
  const card = {
3319
3463
  schema: '2.0',
@@ -3463,15 +3607,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3463
3607
  label: '⏱️ 空闲',
3464
3608
  fields: [{ key: 'idle.watchdogMinutes', label: '空闲超时(分钟, 0关闭)', type: 'input' }],
3465
3609
  },
3466
- {
3467
- id: 'output',
3468
- label: '📤 输出',
3469
- fields: [
3470
- { key: 'output.showThinking', label: '显示思考过程', type: 'boolean' },
3471
- { key: 'output.showToolUse', label: '显示工具调用', type: 'boolean' },
3472
- { key: 'output.showToolResult', label: '显示工具结果', type: 'boolean' },
3473
- ],
3474
- },
3475
3610
  {
3476
3611
  id: 'logging',
3477
3612
  label: '📝 日志',
@@ -3685,7 +3820,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3685
3820
  label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
3686
3821
  });
3687
3822
  elements.push({ tag: 'hr' });
3688
- elements.push(bar);
3823
+ elements.push(...bar);
3689
3824
  }
3690
3825
  }
3691
3826
  return {