lark-remote 0.1.2 → 0.1.4

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 (50) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.en.md +2 -5
  3. package/README.md +1 -4
  4. package/dist/bridge/index.d.ts +5 -1
  5. package/dist/bridge/index.js +12 -2
  6. package/dist/card/card-budget.js +1 -2
  7. package/dist/card/run-renderer.js +1 -0
  8. package/dist/card/run-state.d.ts +4 -0
  9. package/dist/config/codex-config.d.ts +3 -5
  10. package/dist/config/codex-config.js +7 -7
  11. package/dist/config/dir.d.ts +1 -0
  12. package/dist/config/dir.js +4 -0
  13. package/dist/config/index.d.ts +0 -10
  14. package/dist/config/index.js +0 -11
  15. package/dist/connector/index.js +28 -0
  16. package/dist/index.js +74 -21
  17. package/dist/router/config/codex.js +8 -12
  18. package/dist/router/config/index.d.ts +10 -0
  19. package/dist/router/config/index.js +17 -0
  20. package/dist/router/index.d.ts +40 -1
  21. package/dist/router/index.js +514 -48
  22. package/dist/router/utils.d.ts +3 -0
  23. package/dist/router/utils.js +15 -3
  24. package/dist/runner/claude/runner.d.ts +0 -1
  25. package/dist/runner/claude/runner.js +1 -1
  26. package/dist/runner/codex/runner.d.ts +0 -2
  27. package/dist/runner/codex/runner.js +1 -1
  28. package/dist/runner/common/spawning-runner.d.ts +0 -1
  29. package/dist/runner/common/spawning-runner.js +2 -4
  30. package/dist/runner/kimi/runner.d.ts +0 -1
  31. package/dist/runner/kimi/runner.js +1 -1
  32. package/dist/runner/opencode/runner.d.ts +0 -2
  33. package/dist/runner/opencode/runner.js +1 -1
  34. package/dist/runner/pi/runner.d.ts +0 -1
  35. package/dist/runner/pi/runner.js +1 -1
  36. package/dist/runner/probe.d.ts +45 -0
  37. package/dist/runner/probe.js +87 -0
  38. package/dist/runner/types.d.ts +3 -0
  39. package/dist/session/claude/sessions.d.ts +1 -1
  40. package/dist/session/claude/sessions.js +77 -31
  41. package/dist/session/codex/rollout-reader.js +9 -0
  42. package/dist/session/kimi/sessions.d.ts +3 -1
  43. package/dist/session/kimi/sessions.js +9 -3
  44. package/dist/session/opencode/sessions.d.ts +0 -2
  45. package/dist/session/opencode/sessions.js +1 -1
  46. package/dist/startup-contact.d.ts +4 -2
  47. package/dist/startup-contact.js +7 -4
  48. package/dist/workspace/index.d.ts +19 -1
  49. package/dist/workspace/index.js +51 -5
  50. package/package.json +39 -4
@@ -12,7 +12,8 @@ import { markdownDiv, sessionEventPanel } from './card-helpers.js';
12
12
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
13
13
  import { atomicWrite } from '../persistence/atomic-write.js';
14
14
  /** Config card builder - delegates to per-agent builders */
15
- import { getConfigBuilder, listRegisteredAgents } from './config/index.js';
15
+ import { getConfigBuilder, listRegisteredAgents, sortAgentsByAvailability, } from './config/index.js';
16
+ import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
16
17
  import { buildConfigCardFromTabs } from './config/common/render.js';
17
18
  /** Max events to read for auto-resume and /resume cards.
18
19
  * Limits events at the read stage so enforceCardBudget rarely needs to truncate.
@@ -20,6 +21,21 @@ import { buildConfigCardFromTabs } from './config/common/render.js';
20
21
  const AUTO_RESUME_MAX_EVENTS = 5;
21
22
  /** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
22
23
  const RESUME_PAGE_SIZE = 5;
24
+ /** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
25
+ const ACTIVE_PAGE_SIZE = 20;
26
+ /**
27
+ * /order 列表页大小;指令超过此数量时显示分页导航栏。
28
+ * 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
29
+ * = 61 个 body 元素,触发飞书 ErrCode 11310 "element exceeds the limit"
30
+ * (21+ 条指令时第 1 页必炸)。改为 15 行:3*15 - 1 + 2 = 46 个元素,留足余量。
31
+ */
32
+ const ORDER_PAGE_SIZE = 15;
33
+ /**
34
+ * /ws 列表页大小。从 15 降为 5:头部 2 + 5 行 × 3(div + column_set + hr)+
35
+ * 底栏排序 1 + 分页栏 2 ≈ 20 个 body 元素,远低于单卡 60 元素上限(ErrCode 11310),
36
+ * 留足余量。
37
+ */
38
+ const WS_PAGE_SIZE = 5;
23
39
  /** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
24
40
  const RESUME_CONTENT_PREFETCH = 5;
25
41
  /**
@@ -34,6 +50,7 @@ import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
34
50
  * These bypass the serial queue and execute immediately.
35
51
  *
36
52
  * ws.use — /ws list card "使用 <name>"
53
+ * ws.page — /ws list card "上一页/下一页"
37
54
  * queue.immediate — queue card "立即执行" button (§9.6)
38
55
  * queue.cancel — queue card "撤销" button
39
56
  * queue.diagnose — queue card "诊断" button
@@ -42,6 +59,7 @@ import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
42
59
  * new-session — clear sessionId only
43
60
  * stop — interrupt current run
44
61
  * ls.file — send file only
62
+ * ws.page — paginate /ws list only
45
63
  * ws.remove — delete workspace alias only
46
64
  * resume.use — set sessionId + agent for correct reader routing
47
65
  * help.* — read-only help commands
@@ -61,7 +79,10 @@ export function isImmediateAction(cmd) {
61
79
  cmd === 'ls.switch' ||
62
80
  cmd === 'ls.page' ||
63
81
  cmd === 'resume.page' || // control operation: paginate only, never spawns claude
82
+ cmd === 'active.page' || // control operation: paginate active card
83
+ cmd === 'ws.page' || // control operation: paginate /ws list only
64
84
  cmd === 'ws.remove' ||
85
+ cmd === 'ws.sort' || // control operation: toggle sort mode only
65
86
  cmd === 'resume.use' ||
66
87
  cmd === 'ws.use' ||
67
88
  cmd === 'queue.immediate' ||
@@ -70,12 +91,15 @@ export function isImmediateAction(cmd) {
70
91
  cmd === 'queue.edit' ||
71
92
  cmd === 'queue.input' ||
72
93
  cmd === 'order.delete' ||
94
+ cmd === 'order.page' ||
73
95
  cmd === 'config.toggle' ||
74
96
  cmd === 'config.set' ||
75
97
  cmd === 'config.input' ||
76
98
  cmd === 'config.save');
77
99
  }
78
100
  export class CommandRouter {
101
+ /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
102
+ static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
79
103
  sessionStore;
80
104
  bridge;
81
105
  config;
@@ -99,6 +123,11 @@ export class CommandRouter {
99
123
  * millisecond); the counter guarantees uniqueness regardless of timing.
100
124
  */
101
125
  orderExecKeyCounter = 0;
126
+ /**
127
+ * Per-user sort preference for /ws list (memory-only, not persisted).
128
+ * Default 'recent' (most recently used first), can toggle to 'alpha'.
129
+ */
130
+ wsSortPreference = new Map();
102
131
  /**
103
132
  * Session reader registry for multi-agent path. Resolves the right
104
133
  * reader by `config.defaultAgent` (and per-agent override params).
@@ -195,15 +224,18 @@ export class CommandRouter {
195
224
  case 'ls.page':
196
225
  return this.handleLsPage(value, ctx);
197
226
  case 'ws.use':
198
- await this.bridge.sendResult(this.cmdWs(['use', value.name ?? ''], ctx), ctx);
199
- return;
227
+ return await this.handleWsUse(value, ctx);
228
+ case 'ws.sort':
229
+ return await this.handleWsSort(value, ctx);
230
+ case 'ws.page':
231
+ return await this.handleWsPage(value, ctx);
200
232
  case 'ws.remove':
201
233
  return await this.handleWsRemove(value, ctx);
202
234
  case 'resume.use': {
203
- // resume.use can carry an agent field when called from /resume [agent] list
235
+ // resume.use carries an agent field from /resume [agent] list cards
236
+ // (both active and completed cards include the agent field).
204
237
  const resumeAgent = value.agent ?? this.config.defaultAgent;
205
- // Validate agent is a valid type
206
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
238
+ const validAgents = CommandRouter.VALID_AGENTS;
207
239
  const resolvedAgent = validAgents.includes(resumeAgent)
208
240
  ? resumeAgent
209
241
  : this.config.defaultAgent;
@@ -226,6 +258,8 @@ export class CommandRouter {
226
258
  }
227
259
  case 'resume.page':
228
260
  return this.handleResumePage(value, ctx);
261
+ case 'active.page':
262
+ return this.handleActivePage(value, ctx);
229
263
  case 'new-session':
230
264
  this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
231
265
  clearSessionCwd: true,
@@ -260,6 +294,8 @@ export class CommandRouter {
260
294
  return await this.handleQueueInput(value, ctx);
261
295
  case 'order.delete':
262
296
  return await this.handleOrderDelete(value, ctx);
297
+ case 'order.page':
298
+ return await this.handleOrderPage(value, ctx);
263
299
  case 'config.toggle':
264
300
  case 'config.set':
265
301
  case 'config.input':
@@ -633,9 +669,11 @@ export class CommandRouter {
633
669
  const inputTask = this.bridge.getQueuedTask(workspace, messageId);
634
670
  if (!inputTask) {
635
671
  // Task already left the queue (began or was cancelled) before we could
636
- // register the replacement. The user must be told the edit failed.
637
- await this.bridge.sendResult({ text: '⚠️ 任务已不在队列中(可能已开始执行或被撤销)' }, ctx);
638
- return;
672
+ // register the replacement. Return a toast so the edit-form card gets
673
+ // dismissed — sendResult alone leaves the card stuck in the edit state
674
+ // (Feishu keeps the pre-click card when the callback response has no
675
+ // toast/card field).
676
+ return { toast: { type: 'info', content: '任务已不在队列中(可能已开始执行或被撤销)' } };
639
677
  }
640
678
  this.bridge.setTaskReplacement(workspace, messageId, async () => {
641
679
  await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask.binding });
@@ -767,8 +805,7 @@ export class CommandRouter {
767
805
  */
768
806
  async handleResumePage(value, ctx) {
769
807
  const resumeAgent = value.agent ?? this.config.defaultAgent;
770
- // Validate agent is a valid type (same as resume.use); fall back to default.
771
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
808
+ const validAgents = CommandRouter.VALID_AGENTS;
772
809
  const resolvedAgent = validAgents.includes(resumeAgent)
773
810
  ? resumeAgent
774
811
  : this.config.defaultAgent;
@@ -804,6 +841,21 @@ export class CommandRouter {
804
841
  await this.bridge.updateCardInPlace(result.card, ctx);
805
842
  return { toast: { type: 'success', content: '' } };
806
843
  }
844
+ /**
845
+ * Handle active.page: paginate the /active run list in place.
846
+ * Mirrors handleResumePage — updates the same card, never sends a new one.
847
+ */
848
+ async handleActivePage(value, ctx) {
849
+ const rawOffset = Number(value.offset);
850
+ const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
851
+ const alignedOffset = Math.floor(offset / ACTIVE_PAGE_SIZE) * ACTIVE_PAGE_SIZE;
852
+ const result = this.cmdActive([], ctx, alignedOffset);
853
+ if (!result.card) {
854
+ return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
855
+ }
856
+ await this.bridge.updateCardInPlace(result.card, ctx);
857
+ return { toast: { type: 'success', content: '' } };
858
+ }
807
859
  /**
808
860
  * Handle ls.browse: browse directory without switching cwd.
809
861
  */
@@ -886,11 +938,56 @@ export class CommandRouter {
886
938
  }
887
939
  // Delete the order
888
940
  this.orderStore.remove(orderId);
889
- // Refresh the order list and update card in place
890
- const result = this.cmdOrder([], ctx);
941
+ // Refresh the order list and update card in place, preserving current page
942
+ const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
943
+ const result = this.cmdOrder([], ctx, currentOffset);
891
944
  await this.bridge.updateCardInPlace(result.card, ctx);
892
945
  return { toast: { type: 'success', content: '已删除指令' } };
893
946
  }
947
+ /**
948
+ * Handle order.page: paginate the /order list in place.
949
+ * Mirrors handleLsPage — updates the same card, never sends a new one.
950
+ */
951
+ async handleOrderPage(value, ctx) {
952
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
953
+ // cmdOrder internally clamps stale/out-of-range offsets
954
+ const result = this.cmdOrder([], ctx, offset);
955
+ await this.bridge.updateCardInPlace(result.card, ctx);
956
+ return { toast: { type: 'success', content: '' } };
957
+ }
958
+ /**
959
+ * Handle ws.use card action: switch to the workspace, then refresh the /ws
960
+ * list card in place so "recent" sort order is immediately visible.
961
+ *
962
+ * Unlike the command-line `/ws use` (which returns text), the card action
963
+ * uses toast + inplace refresh for a cleaner UX (matching ws.remove/ws.sort).
964
+ * If auto-resume produces a card, it is sent as a separate reply.
965
+ */
966
+ async handleWsUse(value, ctx) {
967
+ const name = value.name ?? '';
968
+ const useResult = this.cmdWs(['use', name], ctx);
969
+ // Error cases (workspace not found, path invalid, etc.): relay as error toast
970
+ if (useResult.text && !useResult.text.includes('已切换')) {
971
+ return { toast: { type: 'error', content: useResult.text } };
972
+ }
973
+ // Success: if auto-resume produced a card, send it as a reply
974
+ if (useResult.card) {
975
+ await this.bridge.sendResult(useResult, ctx);
976
+ }
977
+ // Refresh the /ws list card in place so "recent" sort is immediately visible
978
+ const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
979
+ const refreshed = this.cmdWs([], ctx, currentOffset);
980
+ if (refreshed.card) {
981
+ await this.bridge.updateCardInPlace(refreshed.card, ctx);
982
+ }
983
+ // Toast feedback (suppress text when auto-resume card was already sent)
984
+ return {
985
+ toast: {
986
+ type: 'success',
987
+ content: useResult.card ? '' : (useResult.text ?? `已切换到 workspace "${name}"`),
988
+ },
989
+ };
990
+ }
894
991
  /**
895
992
  * Handle ws.remove: remove the workspace alias and refresh the /ws list card
896
993
  * in place (mirrors handleOrderDelete). Without this the stale alias would
@@ -904,11 +1001,40 @@ export class CommandRouter {
904
1001
  // Execute the removal (consumes the write-side effect; the returned text
905
1002
  // is discarded — feedback flows through the toast + refreshed card).
906
1003
  this.cmdWs(['remove', name], ctx);
907
- // Rebuild the /ws list card and update it in place.
908
- const refreshed = this.cmdWs([], ctx);
1004
+ // Rebuild the /ws list card and update it in place, preserving the page
1005
+ // the user was on when they clicked 删除.
1006
+ const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1007
+ const refreshed = this.cmdWs([], ctx, currentOffset);
909
1008
  await this.bridge.updateCardInPlace(refreshed.card, ctx);
910
1009
  return { toast: { type: 'success', content: `已删除 workspace "${name}"` } };
911
1010
  }
1011
+ /**
1012
+ * Handle ws.page: paginate the /ws list in place.
1013
+ * Mirrors handleOrderPage — updates the same card, never sends a new one.
1014
+ */
1015
+ async handleWsPage(value, ctx) {
1016
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1017
+ // cmdWs internally clamps stale/out-of-range offsets
1018
+ const result = this.cmdWs([], ctx, offset);
1019
+ await this.bridge.updateCardInPlace(result.card, ctx);
1020
+ return { toast: { type: 'success', content: '' } };
1021
+ }
1022
+ /**
1023
+ * Handle ws.sort: toggle sort preference and refresh the /ws list card.
1024
+ * Sort preference is memory-only (not persisted); restart resets to 'recent'.
1025
+ */
1026
+ async handleWsSort(value, ctx) {
1027
+ const userId = ctx.userId;
1028
+ const current = this.wsSortPreference.get(userId) ?? 'recent';
1029
+ const next = current === 'recent' ? 'alpha' : 'recent';
1030
+ this.wsSortPreference.set(userId, next);
1031
+ const result = this.cmdWs([], ctx, 0);
1032
+ await this.bridge.updateCardInPlace(result.card, ctx);
1033
+ const nextLabel = next === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
1034
+ return {
1035
+ toast: { type: 'success', content: `已切换为 ${nextLabel}` },
1036
+ };
1037
+ }
912
1038
  /**
913
1039
  * `ls.file` card button: send file to Feishu, rejecting if > 30MB.
914
1040
  */
@@ -1323,7 +1449,7 @@ export class CommandRouter {
1323
1449
  return await this.cmdReconnect();
1324
1450
  case 'config':
1325
1451
  case 'c':
1326
- return this.cmdConfig(args, ctx);
1452
+ return await this.cmdConfig(args, ctx);
1327
1453
  case 'order':
1328
1454
  case 'o':
1329
1455
  return this.cmdOrder(args, ctx);
@@ -1989,7 +2115,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
1989
2115
  return { text: `读取目录失败: ${err.message}` };
1990
2116
  }
1991
2117
  }
1992
- cmdWs(args, ctx) {
2118
+ cmdWs(args, ctx, offset = 0) {
1993
2119
  const sub = args[0]?.toLowerCase();
1994
2120
  switch (sub) {
1995
2121
  case 'save': {
@@ -2022,6 +2148,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2022
2148
  catch {
2023
2149
  return { text: `路径无效: ${wsPath}` };
2024
2150
  }
2151
+ // Record usage timestamp AFTER successful path validation
2152
+ this.workspaceStore.touch(name);
2025
2153
  // Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
2026
2154
  return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
2027
2155
  }
@@ -2038,6 +2166,29 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2038
2166
  default: {
2039
2167
  const entries = this.workspaceStore.list();
2040
2168
  const currentCwd = this.sessionStore.getCwd(ctx.userId);
2169
+ const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
2170
+ // Sort entries by the current preference (view concern, not stored)
2171
+ if (sortMode === 'recent') {
2172
+ // Most recently used first; same lastUsedAt → alphabetical by name (deterministic)
2173
+ entries.sort((a, b) => {
2174
+ if (b.lastUsedAt !== a.lastUsedAt)
2175
+ return b.lastUsedAt - a.lastUsedAt;
2176
+ return a.name.localeCompare(b.name);
2177
+ });
2178
+ }
2179
+ else {
2180
+ // Alphabetical by alias name
2181
+ entries.sort((a, b) => a.name.localeCompare(b.name));
2182
+ }
2183
+ // Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
2184
+ // offsets so prev/next always step by WS_PAGE_SIZE.
2185
+ const totalCount = entries.length;
2186
+ const totalPages = Math.max(1, Math.ceil(totalCount / WS_PAGE_SIZE));
2187
+ const maxOffset = Math.max(0, (totalPages - 1) * WS_PAGE_SIZE);
2188
+ const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
2189
+ const currentPage = Math.floor(safeOffset / WS_PAGE_SIZE) + 1;
2190
+ const pageEntries = entries.slice(safeOffset, safeOffset + WS_PAGE_SIZE);
2191
+ const hasPagination = totalCount > WS_PAGE_SIZE;
2041
2192
  // Build body elements: current cwd + workspace list with dividers
2042
2193
  const bodyElements = [];
2043
2194
  // Current working directory section
@@ -2045,18 +2196,56 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2045
2196
  tag: 'div',
2046
2197
  text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
2047
2198
  });
2048
- bodyElements.push({ tag: 'hr' });
2049
2199
  if (entries.length === 0) {
2200
+ bodyElements.push({ tag: 'hr' });
2050
2201
  bodyElements.push({
2051
2202
  tag: 'div',
2052
2203
  text: { tag: 'lark_md', content: '没有保存的 workspace' },
2053
2204
  });
2054
2205
  }
2055
2206
  else {
2056
- entries.forEach(([name, p]) => {
2207
+ // Sort mode indicator + toggle button: placed above the list so the user
2208
+ // sees "current mode" and "switch to X" before scanning entries.
2209
+ // On narrow (mobile) screens, the label and button must convey both
2210
+ // what-is-active and what-clicking-does to avoid ambiguity.
2211
+ const currentLabel = sortMode === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
2212
+ const switchLabel = sortMode === 'recent' ? '🔤 字母顺序' : '🕐 最近使用';
2213
+ bodyElements.push({ tag: 'hr' });
2214
+ bodyElements.push({
2215
+ tag: 'column_set',
2216
+ columns: [
2217
+ {
2218
+ tag: 'column',
2219
+ width: 'weighted',
2220
+ weight: 1,
2221
+ vertical_align: 'center',
2222
+ elements: [
2223
+ {
2224
+ tag: 'div',
2225
+ text: { tag: 'lark_md', content: `排序:${currentLabel}` },
2226
+ },
2227
+ ],
2228
+ },
2229
+ {
2230
+ tag: 'column',
2231
+ width: 'auto',
2232
+ elements: [
2233
+ {
2234
+ tag: 'button',
2235
+ text: { tag: 'plain_text', content: `切换为 ${switchLabel}` },
2236
+ type: 'default',
2237
+ size: 'small',
2238
+ behaviors: [{ type: 'callback', value: { cmd: 'ws.sort' } }],
2239
+ },
2240
+ ],
2241
+ },
2242
+ ],
2243
+ });
2244
+ bodyElements.push({ tag: 'hr' });
2245
+ for (const entry of pageEntries) {
2057
2246
  bodyElements.push({
2058
2247
  tag: 'div',
2059
- text: { tag: 'lark_md', content: `**${name}** → \`${p}\`` },
2248
+ text: { tag: 'lark_md', content: `**${entry.name}** → \`${entry.path}\`` },
2060
2249
  });
2061
2250
  // Use column_set+column for 2.0 (action tag not supported in 2.0)
2062
2251
  bodyElements.push({
@@ -2071,7 +2260,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2071
2260
  text: { tag: 'plain_text', content: '切换' },
2072
2261
  type: 'primary',
2073
2262
  size: 'small',
2074
- behaviors: [{ type: 'callback', value: { cmd: 'ws.use', name } }],
2263
+ behaviors: [
2264
+ {
2265
+ type: 'callback',
2266
+ value: { cmd: 'ws.use', name: entry.name, offset: safeOffset },
2267
+ },
2268
+ ],
2075
2269
  },
2076
2270
  ],
2077
2271
  },
@@ -2084,15 +2278,86 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2084
2278
  text: { tag: 'plain_text', content: '删除' },
2085
2279
  type: 'danger',
2086
2280
  size: 'small',
2087
- behaviors: [{ type: 'callback', value: { cmd: 'ws.remove', name } }],
2281
+ behaviors: [
2282
+ {
2283
+ type: 'callback',
2284
+ value: { cmd: 'ws.remove', name: entry.name, offset: safeOffset },
2285
+ },
2286
+ ],
2088
2287
  },
2089
2288
  ],
2090
2289
  },
2091
2290
  ],
2092
2291
  });
2093
2292
  bodyElements.push({ tag: 'hr' });
2094
- });
2293
+ }
2095
2294
  bodyElements.pop();
2295
+ // Pagination bar — separate row below the list (not crowded with sort
2296
+ // toggle; mobile-friendly vertical layout).
2297
+ if (hasPagination) {
2298
+ const hasPrev = safeOffset > 0;
2299
+ const hasNext = safeOffset + WS_PAGE_SIZE < totalCount;
2300
+ const pageColumns = [];
2301
+ if (hasPrev) {
2302
+ pageColumns.push({
2303
+ tag: 'column',
2304
+ width: 'auto',
2305
+ vertical_align: 'center',
2306
+ elements: [
2307
+ {
2308
+ tag: 'button',
2309
+ text: { tag: 'plain_text', content: '⬅' },
2310
+ type: 'default',
2311
+ size: 'small',
2312
+ behaviors: [
2313
+ {
2314
+ type: 'callback',
2315
+ value: { cmd: 'ws.page', offset: safeOffset - WS_PAGE_SIZE },
2316
+ },
2317
+ ],
2318
+ },
2319
+ ],
2320
+ });
2321
+ }
2322
+ pageColumns.push({
2323
+ tag: 'column',
2324
+ width: 'weighted',
2325
+ weight: 1,
2326
+ vertical_align: 'center',
2327
+ elements: [
2328
+ {
2329
+ tag: 'div',
2330
+ text: {
2331
+ tag: 'lark_md',
2332
+ content: `**${currentPage}/${totalPages}**(${totalCount})`,
2333
+ },
2334
+ },
2335
+ ],
2336
+ });
2337
+ if (hasNext) {
2338
+ pageColumns.push({
2339
+ tag: 'column',
2340
+ width: 'auto',
2341
+ vertical_align: 'center',
2342
+ elements: [
2343
+ {
2344
+ tag: 'button',
2345
+ text: { tag: 'plain_text', content: '➡' },
2346
+ type: 'default',
2347
+ size: 'small',
2348
+ behaviors: [
2349
+ {
2350
+ type: 'callback',
2351
+ value: { cmd: 'ws.page', offset: safeOffset + WS_PAGE_SIZE },
2352
+ },
2353
+ ],
2354
+ },
2355
+ ],
2356
+ });
2357
+ }
2358
+ bodyElements.push({ tag: 'hr' });
2359
+ bodyElements.push({ tag: 'column_set', columns: pageColumns });
2360
+ }
2096
2361
  }
2097
2362
  const card = {
2098
2363
  schema: '2.0',
@@ -2115,7 +2380,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2115
2380
  const entry = this.sessionStore.get(ctx.userId);
2116
2381
  const cwd = entry?.cwd;
2117
2382
  // Valid agent kinds for the /resume [agent] [N] feature
2118
- const VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
2383
+ const VALID_AGENTS = CommandRouter.VALID_AGENTS;
2119
2384
  // Parse arguments: /resume [agent] [N] or /resume [sessionId] or /resume [agent] [sessionId]
2120
2385
  let agentKind = this.config.defaultAgent;
2121
2386
  let limit;
@@ -2470,7 +2735,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2470
2735
  };
2471
2736
  return { card };
2472
2737
  }
2473
- cmdActive(_args, _ctx) {
2738
+ cmdActive(_args, _ctx, offset = 0) {
2474
2739
  // List all active runs from bridge memory (not from file system scan)
2475
2740
  // New semantics (2026-07-20): only shows runs started by THIS bridge process
2476
2741
  const activeRuns = this.bridge.getActiveRuns();
@@ -2479,18 +2744,40 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2479
2744
  return { text: '当前没有正在进行中的任务' };
2480
2745
  }
2481
2746
  // Build CardKit 2.0 card showing memory-based active runs
2482
- return this.buildActiveCardFromMemory(activeRuns, activeBashRuns);
2747
+ return this.buildActiveCardFromMemory(activeRuns, activeBashRuns, offset);
2483
2748
  }
2484
2749
  /**
2485
2750
  * Build CardKit 2.0 /active card from bridge memory (not file system).
2486
2751
  * Shows only runs that were started by THIS bridge process.
2752
+ * Paginates with ACTIVE_PAGE_SIZE (default 20) items per page.
2487
2753
  */
2488
- buildActiveCardFromMemory(activeRuns, activeBashRuns) {
2754
+ buildActiveCardFromMemory(activeRuns, activeBashRuns, offset = 0) {
2755
+ const totalCount = activeRuns.length + activeBashRuns.length;
2756
+ const pageSize = ACTIVE_PAGE_SIZE;
2757
+ const safeOffset = Math.max(0, Math.min(offset, Math.max(0, totalCount - 1)));
2758
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
2759
+ const currentPage = Math.floor(safeOffset / pageSize) + 1;
2760
+ // Slice: distribute offset across agent runs first, then bash runs
2489
2761
  const elements = [];
2762
+ // Page info
2763
+ if (totalPages > 1) {
2764
+ elements.push({
2765
+ tag: 'div',
2766
+ text: {
2767
+ tag: 'lark_md',
2768
+ content: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
2769
+ },
2770
+ });
2771
+ }
2772
+ let remaining = pageSize;
2773
+ let skipped = safeOffset;
2490
2774
  // Agent runs section
2491
- if (activeRuns.length > 0) {
2775
+ if (activeRuns.length > 0 && skipped < activeRuns.length && remaining > 0) {
2492
2776
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 🤖 Agent 任务' } });
2493
- for (const run of activeRuns) {
2777
+ const start = skipped;
2778
+ const end = Math.min(start + remaining, activeRuns.length);
2779
+ for (let i = start; i < end; i++) {
2780
+ const run = activeRuns[i];
2494
2781
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2495
2782
  elements.push({
2496
2783
  tag: 'div',
@@ -2513,11 +2800,19 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2513
2800
  });
2514
2801
  elements.push({ tag: 'hr' });
2515
2802
  }
2803
+ remaining -= end - start;
2804
+ skipped = 0;
2805
+ }
2806
+ else {
2807
+ skipped -= activeRuns.length;
2516
2808
  }
2517
2809
  // Bash runs section
2518
- if (activeBashRuns.length > 0) {
2810
+ if (activeBashRuns.length > 0 && skipped < activeBashRuns.length && remaining > 0) {
2519
2811
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 💻 Bash 命令' } });
2520
- for (const run of activeBashRuns) {
2812
+ const start = skipped;
2813
+ const end = Math.min(start + remaining, activeBashRuns.length);
2814
+ for (let i = start; i < end; i++) {
2815
+ const run = activeBashRuns[i];
2521
2816
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2522
2817
  elements.push({
2523
2818
  tag: 'div',
@@ -2538,6 +2833,38 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2538
2833
  elements.push({ tag: 'hr' });
2539
2834
  }
2540
2835
  }
2836
+ // Pagination buttons
2837
+ if (totalPages > 1) {
2838
+ const paginationButtons = [];
2839
+ if (currentPage > 1) {
2840
+ const prevOffset = (currentPage - 2) * pageSize;
2841
+ paginationButtons.push({
2842
+ tag: 'button',
2843
+ text: { tag: 'plain_text', content: '◀ 上一页' },
2844
+ type: 'default',
2845
+ size: 'small',
2846
+ behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: prevOffset } }],
2847
+ });
2848
+ }
2849
+ if (currentPage < totalPages) {
2850
+ const nextOffset = currentPage * pageSize;
2851
+ paginationButtons.push({
2852
+ tag: 'button',
2853
+ text: { tag: 'plain_text', content: '下一页 ▶' },
2854
+ type: 'primary',
2855
+ size: 'small',
2856
+ behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: nextOffset } }],
2857
+ });
2858
+ }
2859
+ elements.push({
2860
+ tag: 'column_set',
2861
+ columns: paginationButtons.map((btn) => ({
2862
+ tag: 'column',
2863
+ width: 'auto',
2864
+ elements: [btn],
2865
+ })),
2866
+ });
2867
+ }
2541
2868
  const card = {
2542
2869
  schema: '2.0',
2543
2870
  config: { wide_screen_mode: true },
@@ -2558,7 +2885,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2558
2885
  return { text: '重连失败,请检查网络和配置' };
2559
2886
  }
2560
2887
  }
2561
- cmdConfig(args, ctx) {
2888
+ async cmdConfig(args, ctx) {
2889
+ // Ensure probe cache is populated before building the card.
2890
+ await probeAllAgents();
2562
2891
  // Check if this is a direct set command: /config <key> <value>
2563
2892
  // Command-style set: immediate write to disk, clear pendingConfig
2564
2893
  if (args.length >= 2) {
@@ -2604,25 +2933,60 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2604
2933
  // 使用 pendingConfig(若存在)或当前 config
2605
2934
  const displayConfig = this.pendingConfig ?? this.config;
2606
2935
  // 定义字段分组:defaultAgent 选择器 + 该 agent 的配置字段 + idle.watchdogMinutes +
2607
- // output/logging。stopGraceMs / claude.binary 等实现细节走 YAML,不在卡片暴露。
2936
+ // output/logging。stopGraceMs 等实现细节走 YAML,不在卡片暴露。
2608
2937
  // 根据 defaultAgent 构建完整的 Agent 配置字段组
2609
2938
  // 使用 agent config builder 获取该 agent 的配置字段
2610
2939
  const defaultAgent = displayConfig.defaultAgent ?? 'claude';
2611
2940
  const configBuilder = getConfigBuilder(defaultAgent);
2612
- const agentConfigFields = configBuilder.buildFields(displayConfig);
2613
- // 使用共享渲染模块构建 tabs 和卡片
2614
- // 添加 defaultAgent 选择器作为第一个字段
2615
- agentConfigFields.unshift({
2616
- key: 'defaultAgent',
2617
- label: '默认 Agent',
2618
- type: 'select',
2619
- options: listRegisteredAgents(),
2941
+ // Build defaultAgent selector with availability annotations.
2942
+ // Direct inline construction (not via ConfigField) so that text (display label)
2943
+ // and value (agentKind for config storage) can differ — uninstalled agents get
2944
+ // a "⚠️ (未安装)" suffix in the label while the value stays clean.
2945
+ // Uninstalled agents sink to the bottom; installed ones keep registration
2946
+ // order (claude → codex → pi → opencode → kimi).
2947
+ const allAgents = sortAgentsByAvailability(listRegisteredAgents(), getCachedAvailability);
2948
+ const agentOptions = allAgents.map((kind) => {
2949
+ const available = getCachedAvailability(kind);
2950
+ const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
2951
+ return { text: { tag: 'plain_text', content: label }, value: kind };
2620
2952
  });
2953
+ const selectedAgent = displayConfig.defaultAgent ?? 'claude';
2954
+ const agentSelector = {
2955
+ tag: 'column_set',
2956
+ flex_mode: 'none',
2957
+ columns: [
2958
+ {
2959
+ tag: 'column',
2960
+ width: 'weighted',
2961
+ weight: 2,
2962
+ vertical_align: 'center',
2963
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: '默认 Agent' } }],
2964
+ },
2965
+ {
2966
+ tag: 'column',
2967
+ width: 'weighted',
2968
+ weight: 3,
2969
+ vertical_align: 'center',
2970
+ elements: [
2971
+ {
2972
+ tag: 'select_static',
2973
+ placeholder: { tag: 'plain_text', content: '请选择' },
2974
+ options: agentOptions,
2975
+ initial_option: selectedAgent,
2976
+ behaviors: [{ type: 'callback', value: { cmd: 'config.set', key: 'defaultAgent' } }],
2977
+ },
2978
+ ],
2979
+ },
2980
+ ],
2981
+ };
2982
+ // Agent config fields — no longer include defaultAgent as a ConfigField
2983
+ // (it's rendered inline above with text/value separation).
2984
+ const agentConfigFieldsNoSelector = configBuilder.buildFields(displayConfig);
2621
2985
  const tabs = [
2622
2986
  {
2623
2987
  id: 'agent',
2624
2988
  label: `🤖 ${agentDisplayName(defaultAgent)}`,
2625
- fields: agentConfigFields,
2989
+ fields: agentConfigFieldsNoSelector,
2626
2990
  },
2627
2991
  {
2628
2992
  id: 'idle',
@@ -2653,11 +3017,30 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2653
3017
  ];
2654
3018
  // 使用共享渲染模块构建卡片
2655
3019
  const card = buildConfigCardFromTabs(tabs, displayConfig);
3020
+ // Inject the agent selector as the first element in the agent tab section,
3021
+ // right after the "**🤖 ..." header, so text/value can differ for uninstalled agents.
3022
+ // NOTE: findIndex relies on the agent tab header starting with "**🤖" — if the
3023
+ // tab label format changes, this fallback will fire and the selector moves to top.
3024
+ const body = card.body;
3025
+ if (body && Array.isArray(body.elements)) {
3026
+ const headerIdx = body.elements.findIndex((el) => typeof el === 'object' &&
3027
+ el !== null &&
3028
+ el.tag === 'div' &&
3029
+ el.text?.tag === 'lark_md' &&
3030
+ (el.text?.content ?? '').startsWith('**🤖'));
3031
+ if (headerIdx >= 0) {
3032
+ body.elements.splice(headerIdx + 1, 0, agentSelector);
3033
+ }
3034
+ else {
3035
+ getLogger().warn('[buildConfigCard] agent tab header not found, falling back to unshift');
3036
+ body.elements.unshift(agentSelector);
3037
+ }
3038
+ }
2656
3039
  return { card };
2657
3040
  }
2658
- cmdOrder(args, _ctx) {
3041
+ cmdOrder(args, _ctx, offset = 0) {
2659
3042
  const sub = args[0]?.toLowerCase();
2660
- // /order — list orders (simple CardKit 2.0 list, no tabs)
3043
+ // /order — list orders (CardKit 2.0 with pagination)
2661
3044
  if (!sub || sub === 'list') {
2662
3045
  this.orderStore.reload();
2663
3046
  const allOrders = this.orderStore.get();
@@ -2666,8 +3049,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2666
3049
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '暂无指令' } });
2667
3050
  }
2668
3051
  else {
2669
- for (let i = 0; i < allOrders.length; i++) {
2670
- const order = allOrders[i];
3052
+ // Pagination calculations
3053
+ const totalCount = allOrders.length;
3054
+ const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
3055
+ // Clamp offset to last page boundary to avoid empty pages
3056
+ const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
3057
+ const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
3058
+ const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
3059
+ const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
3060
+ const hasPagination = totalCount > ORDER_PAGE_SIZE;
3061
+ for (let i = 0; i < pageOrders.length; i++) {
3062
+ const order = pageOrders[i];
2671
3063
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
2672
3064
  // 文字在上方
2673
3065
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: displayText } });
@@ -2700,7 +3092,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2700
3092
  type: 'danger',
2701
3093
  size: 'small',
2702
3094
  behaviors: [
2703
- { type: 'callback', value: { cmd: 'order.delete', orderId: order.id } },
3095
+ {
3096
+ type: 'callback',
3097
+ value: { cmd: 'order.delete', orderId: order.id, offset: safeOffset },
3098
+ },
2704
3099
  ],
2705
3100
  },
2706
3101
  ],
@@ -2708,10 +3103,81 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2708
3103
  ],
2709
3104
  });
2710
3105
  // 非最后一项,添加分隔线
2711
- if (i < allOrders.length - 1) {
3106
+ if (i < pageOrders.length - 1) {
2712
3107
  elements.push({ tag: 'hr' });
2713
3108
  }
2714
3109
  }
3110
+ // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
3111
+ if (hasPagination) {
3112
+ const hasPrev = safeOffset > 0;
3113
+ const hasNext = safeOffset + ORDER_PAGE_SIZE < totalCount;
3114
+ const pageColumns = [];
3115
+ if (hasPrev) {
3116
+ pageColumns.push({
3117
+ tag: 'column',
3118
+ width: 'auto',
3119
+ vertical_align: 'center',
3120
+ elements: [
3121
+ {
3122
+ tag: 'button',
3123
+ text: { tag: 'plain_text', content: '⬅ 上一页' },
3124
+ type: 'default',
3125
+ size: 'small',
3126
+ behaviors: [
3127
+ {
3128
+ type: 'callback',
3129
+ value: {
3130
+ cmd: 'order.page',
3131
+ offset: safeOffset - ORDER_PAGE_SIZE,
3132
+ },
3133
+ },
3134
+ ],
3135
+ },
3136
+ ],
3137
+ });
3138
+ }
3139
+ pageColumns.push({
3140
+ tag: 'column',
3141
+ width: 'weighted',
3142
+ weight: 1,
3143
+ vertical_align: 'center',
3144
+ elements: [
3145
+ {
3146
+ tag: 'div',
3147
+ text: {
3148
+ tag: 'lark_md',
3149
+ content: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
3150
+ },
3151
+ },
3152
+ ],
3153
+ });
3154
+ if (hasNext) {
3155
+ pageColumns.push({
3156
+ tag: 'column',
3157
+ width: 'auto',
3158
+ vertical_align: 'center',
3159
+ elements: [
3160
+ {
3161
+ tag: 'button',
3162
+ text: { tag: 'plain_text', content: '下一页 ➡' },
3163
+ type: 'default',
3164
+ size: 'small',
3165
+ behaviors: [
3166
+ {
3167
+ type: 'callback',
3168
+ value: {
3169
+ cmd: 'order.page',
3170
+ offset: safeOffset + ORDER_PAGE_SIZE,
3171
+ },
3172
+ },
3173
+ ],
3174
+ },
3175
+ ],
3176
+ });
3177
+ }
3178
+ elements.push({ tag: 'hr' });
3179
+ elements.push({ tag: 'column_set', columns: pageColumns });
3180
+ }
2715
3181
  }
2716
3182
  return {
2717
3183
  card: {