lark-remote 0.1.1 → 0.1.3

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/README.en.md +2 -5
  2. package/README.md +1 -4
  3. package/dist/bridge/index.d.ts +1 -3
  4. package/dist/bridge/index.js +11 -12
  5. package/dist/card/card-budget.js +1 -2
  6. package/dist/card/card-shared.d.ts +8 -0
  7. package/dist/card/card-shared.js +14 -0
  8. package/dist/card/run-renderer.js +1 -0
  9. package/dist/card/run-state.d.ts +4 -0
  10. package/dist/config/codex-config.d.ts +3 -5
  11. package/dist/config/codex-config.js +7 -7
  12. package/dist/config/dir.d.ts +1 -0
  13. package/dist/config/dir.js +4 -0
  14. package/dist/config/index.d.ts +0 -10
  15. package/dist/config/index.js +0 -11
  16. package/dist/connector/index.js +28 -0
  17. package/dist/index.js +89 -41
  18. package/dist/router/config/codex.js +8 -12
  19. package/dist/router/index.d.ts +15 -2
  20. package/dist/router/index.js +314 -44
  21. package/dist/router/utils.d.ts +3 -0
  22. package/dist/router/utils.js +15 -3
  23. package/dist/runner/claude/runner.d.ts +0 -1
  24. package/dist/runner/claude/runner.js +1 -1
  25. package/dist/runner/codex/runner.d.ts +0 -2
  26. package/dist/runner/codex/runner.js +1 -1
  27. package/dist/runner/common/spawning-runner.d.ts +0 -1
  28. package/dist/runner/common/spawning-runner.js +2 -4
  29. package/dist/runner/kimi/runner.d.ts +0 -1
  30. package/dist/runner/kimi/runner.js +1 -1
  31. package/dist/runner/opencode/runner.d.ts +0 -2
  32. package/dist/runner/opencode/runner.js +1 -1
  33. package/dist/runner/pi/runner.d.ts +0 -1
  34. package/dist/runner/pi/runner.js +1 -1
  35. package/dist/runner/probe.d.ts +45 -0
  36. package/dist/runner/probe.js +87 -0
  37. package/dist/runner/types.d.ts +3 -0
  38. package/dist/session/claude/sessions.d.ts +8 -6
  39. package/dist/session/claude/sessions.js +178 -26
  40. package/dist/session/codex/rollout-reader.d.ts +1 -0
  41. package/dist/session/codex/rollout-reader.js +15 -0
  42. package/dist/session/codex/sessions.d.ts +1 -1
  43. package/dist/session/codex/sessions.js +2 -1
  44. package/dist/session/kimi/sessions.d.ts +3 -1
  45. package/dist/session/kimi/sessions.js +42 -6
  46. package/dist/session/opencode/sessions.d.ts +0 -2
  47. package/dist/session/opencode/sessions.js +1 -1
  48. package/dist/startup-contact.d.ts +4 -2
  49. package/dist/startup-contact.js +7 -4
  50. package/package.json +39 -4
@@ -13,6 +13,7 @@ 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
15
  import { getConfigBuilder, listRegisteredAgents } 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,10 @@ 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
+ /** /order 列表页大小;指令超过此数量时显示分页导航栏。 */
27
+ const ORDER_PAGE_SIZE = 20;
23
28
  /** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
24
29
  const RESUME_CONTENT_PREFETCH = 5;
25
30
  /**
@@ -43,7 +48,7 @@ import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
43
48
  * stop — interrupt current run
44
49
  * ls.file — send file only
45
50
  * ws.remove — delete workspace alias only
46
- * resume.use — set sessionId only
51
+ * resume.use — set sessionId + agent for correct reader routing
47
52
  * help.* — read-only help commands
48
53
  * order.delete — delete order only
49
54
  * order.exec is intercepted by index.ts at the enqueue boundary
@@ -61,6 +66,7 @@ export function isImmediateAction(cmd) {
61
66
  cmd === 'ls.switch' ||
62
67
  cmd === 'ls.page' ||
63
68
  cmd === 'resume.page' || // control operation: paginate only, never spawns claude
69
+ cmd === 'active.page' || // control operation: paginate active card
64
70
  cmd === 'ws.remove' ||
65
71
  cmd === 'resume.use' ||
66
72
  cmd === 'ws.use' ||
@@ -70,12 +76,15 @@ export function isImmediateAction(cmd) {
70
76
  cmd === 'queue.edit' ||
71
77
  cmd === 'queue.input' ||
72
78
  cmd === 'order.delete' ||
79
+ cmd === 'order.page' ||
73
80
  cmd === 'config.toggle' ||
74
81
  cmd === 'config.set' ||
75
82
  cmd === 'config.input' ||
76
83
  cmd === 'config.save');
77
84
  }
78
85
  export class CommandRouter {
86
+ /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
87
+ static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
79
88
  sessionStore;
80
89
  bridge;
81
90
  config;
@@ -200,10 +209,10 @@ export class CommandRouter {
200
209
  case 'ws.remove':
201
210
  return await this.handleWsRemove(value, ctx);
202
211
  case 'resume.use': {
203
- // resume.use can carry an agent field when called from /resume [agent] list
212
+ // resume.use carries an agent field from /resume [agent] list cards
213
+ // (both active and completed cards include the agent field).
204
214
  const resumeAgent = value.agent ?? this.config.defaultAgent;
205
- // Validate agent is a valid type
206
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
215
+ const validAgents = CommandRouter.VALID_AGENTS;
207
216
  const resolvedAgent = validAgents.includes(resumeAgent)
208
217
  ? resumeAgent
209
218
  : this.config.defaultAgent;
@@ -226,6 +235,8 @@ export class CommandRouter {
226
235
  }
227
236
  case 'resume.page':
228
237
  return this.handleResumePage(value, ctx);
238
+ case 'active.page':
239
+ return this.handleActivePage(value, ctx);
229
240
  case 'new-session':
230
241
  this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
231
242
  clearSessionCwd: true,
@@ -260,6 +271,8 @@ export class CommandRouter {
260
271
  return await this.handleQueueInput(value, ctx);
261
272
  case 'order.delete':
262
273
  return await this.handleOrderDelete(value, ctx);
274
+ case 'order.page':
275
+ return await this.handleOrderPage(value, ctx);
263
276
  case 'config.toggle':
264
277
  case 'config.set':
265
278
  case 'config.input':
@@ -617,25 +630,57 @@ export class CommandRouter {
617
630
  await this.bridge.sendResult({ text: '⚠️ 缺少新消息内容' }, ctx);
618
631
  return;
619
632
  }
633
+ // RACE FIX: register the replacement closure BEFORE any await. The old
634
+ // code awaited updateMessagePreview first, yielding to the microtask
635
+ // queue. Between that yield and the later setTaskReplacement call, the
636
+ // queue chain could advance: the task's begin path would find no
637
+ // replacement → the original stale closure would run → the user saw
638
+ // "消息已更新" but the agent executed the old prompt.
639
+ //
640
+ // By registering synchronously first, the begin path always finds the
641
+ // replacement if the task starts during the subsequent await. Both
642
+ // setTaskReplacement and the begin-path consumption are synchronous, so
643
+ // there is no interleaving window.
644
+ //
645
+ // D3/Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
646
+ const inputTask = this.bridge.getQueuedTask(workspace, messageId);
647
+ if (!inputTask) {
648
+ // Task already left the queue (began or was cancelled) before we could
649
+ // register the replacement. Return a toast so the edit-form card gets
650
+ // dismissed — sendResult alone leaves the card stuck in the edit state
651
+ // (Feishu keeps the pre-click card when the callback response has no
652
+ // toast/card field).
653
+ return { toast: { type: 'info', content: '任务已不在队列中(可能已开始执行或被撤销)' } };
654
+ }
655
+ this.bridge.setTaskReplacement(workspace, messageId, async () => {
656
+ await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask.binding });
657
+ });
620
658
  // Update the message preview (and editedMessage so handleQueueImmediate
621
659
  // re-enqueues the edited content instead of the stale original closure),
622
660
  // and build the updated queue card. Returning the card in the callback
623
661
  // response makes Feishu render it in place -- a toast-only response leaves
624
662
  // the card stuck in the edit state (Feishu keeps the pre-click card when
625
663
  // the callback response has no `card` field).
664
+ //
665
+ // After the synchronous replacement registration above, it is safe to
666
+ // await here: even if the queue chain advances and the replacement is
667
+ // consumed, the user-facing card update is best-effort (the executing card
668
+ // is updated by updateQueueCardToExecuting instead).
626
669
  const card = await this.bridge.updateMessagePreview(workspace, messageId, newMessage);
627
670
  if (!card) {
628
- await this.bridge.sendResult({ text: '⚠️ 任务已不在队列中' }, ctx);
629
- return;
671
+ // Task left the queue during the await (began or cancelled). The
672
+ // replacement was already consumed (begin path) or cleaned up
673
+ // (cancel/removeFromQueue path). Inform the user the edit didn't stick
674
+ // as a *card update*, but the replacement closure was already in effect.
675
+ if (this.bridge.hasTaskBegan(messageId)) {
676
+ return {
677
+ toast: { type: 'success', content: '消息已更新(任务已开始执行)' },
678
+ };
679
+ }
680
+ return {
681
+ toast: { type: 'info', content: '任务已不在队列中,编辑未生效' },
682
+ };
630
683
  }
631
- // 编辑即生效:自然轮到时(不经立即执行)也必须执行编辑后内容,而不是
632
- // enqueue 时闭包冻结的旧指令。setTaskReplacement 原位替换该任务槽位的闭包,
633
- // 保持队列位置;handleQueueImmediate 后续注册/刷新幂等覆盖。
634
- // D3/Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
635
- const inputTask = this.bridge.getQueuedTask(workspace, messageId);
636
- this.bridge.setTaskReplacement(workspace, messageId, async () => {
637
- await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask?.binding });
638
- });
639
684
  return {
640
685
  toast: { type: 'success', content: '消息已更新' },
641
686
  card: { type: 'raw', data: card },
@@ -737,8 +782,7 @@ export class CommandRouter {
737
782
  */
738
783
  async handleResumePage(value, ctx) {
739
784
  const resumeAgent = value.agent ?? this.config.defaultAgent;
740
- // Validate agent is a valid type (same as resume.use); fall back to default.
741
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
785
+ const validAgents = CommandRouter.VALID_AGENTS;
742
786
  const resolvedAgent = validAgents.includes(resumeAgent)
743
787
  ? resumeAgent
744
788
  : this.config.defaultAgent;
@@ -774,6 +818,21 @@ export class CommandRouter {
774
818
  await this.bridge.updateCardInPlace(result.card, ctx);
775
819
  return { toast: { type: 'success', content: '' } };
776
820
  }
821
+ /**
822
+ * Handle active.page: paginate the /active run list in place.
823
+ * Mirrors handleResumePage — updates the same card, never sends a new one.
824
+ */
825
+ async handleActivePage(value, ctx) {
826
+ const rawOffset = Number(value.offset);
827
+ const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
828
+ const alignedOffset = Math.floor(offset / ACTIVE_PAGE_SIZE) * ACTIVE_PAGE_SIZE;
829
+ const result = this.cmdActive([], ctx, alignedOffset);
830
+ if (!result.card) {
831
+ return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
832
+ }
833
+ await this.bridge.updateCardInPlace(result.card, ctx);
834
+ return { toast: { type: 'success', content: '' } };
835
+ }
777
836
  /**
778
837
  * Handle ls.browse: browse directory without switching cwd.
779
838
  */
@@ -856,11 +915,23 @@ export class CommandRouter {
856
915
  }
857
916
  // Delete the order
858
917
  this.orderStore.remove(orderId);
859
- // Refresh the order list and update card in place
860
- const result = this.cmdOrder([], ctx);
918
+ // Refresh the order list and update card in place, preserving current page
919
+ const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
920
+ const result = this.cmdOrder([], ctx, currentOffset);
861
921
  await this.bridge.updateCardInPlace(result.card, ctx);
862
922
  return { toast: { type: 'success', content: '已删除指令' } };
863
923
  }
924
+ /**
925
+ * Handle order.page: paginate the /order list in place.
926
+ * Mirrors handleLsPage — updates the same card, never sends a new one.
927
+ */
928
+ async handleOrderPage(value, ctx) {
929
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
930
+ // cmdOrder internally clamps stale/out-of-range offsets
931
+ const result = this.cmdOrder([], ctx, offset);
932
+ await this.bridge.updateCardInPlace(result.card, ctx);
933
+ return { toast: { type: 'success', content: '' } };
934
+ }
864
935
  /**
865
936
  * Handle ws.remove: remove the workspace alias and refresh the /ws list card
866
937
  * in place (mirrors handleOrderDelete). Without this the stale alias would
@@ -1293,7 +1364,7 @@ export class CommandRouter {
1293
1364
  return await this.cmdReconnect();
1294
1365
  case 'config':
1295
1366
  case 'c':
1296
- return this.cmdConfig(args, ctx);
1367
+ return await this.cmdConfig(args, ctx);
1297
1368
  case 'order':
1298
1369
  case 'o':
1299
1370
  return this.cmdOrder(args, ctx);
@@ -2085,7 +2156,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2085
2156
  const entry = this.sessionStore.get(ctx.userId);
2086
2157
  const cwd = entry?.cwd;
2087
2158
  // Valid agent kinds for the /resume [agent] [N] feature
2088
- const VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
2159
+ const VALID_AGENTS = CommandRouter.VALID_AGENTS;
2089
2160
  // Parse arguments: /resume [agent] [N] or /resume [sessionId] or /resume [agent] [sessionId]
2090
2161
  let agentKind = this.config.defaultAgent;
2091
2162
  let limit;
@@ -2440,7 +2511,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2440
2511
  };
2441
2512
  return { card };
2442
2513
  }
2443
- cmdActive(_args, _ctx) {
2514
+ cmdActive(_args, _ctx, offset = 0) {
2444
2515
  // List all active runs from bridge memory (not from file system scan)
2445
2516
  // New semantics (2026-07-20): only shows runs started by THIS bridge process
2446
2517
  const activeRuns = this.bridge.getActiveRuns();
@@ -2449,18 +2520,40 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2449
2520
  return { text: '当前没有正在进行中的任务' };
2450
2521
  }
2451
2522
  // Build CardKit 2.0 card showing memory-based active runs
2452
- return this.buildActiveCardFromMemory(activeRuns, activeBashRuns);
2523
+ return this.buildActiveCardFromMemory(activeRuns, activeBashRuns, offset);
2453
2524
  }
2454
2525
  /**
2455
2526
  * Build CardKit 2.0 /active card from bridge memory (not file system).
2456
2527
  * Shows only runs that were started by THIS bridge process.
2528
+ * Paginates with ACTIVE_PAGE_SIZE (default 20) items per page.
2457
2529
  */
2458
- buildActiveCardFromMemory(activeRuns, activeBashRuns) {
2530
+ buildActiveCardFromMemory(activeRuns, activeBashRuns, offset = 0) {
2531
+ const totalCount = activeRuns.length + activeBashRuns.length;
2532
+ const pageSize = ACTIVE_PAGE_SIZE;
2533
+ const safeOffset = Math.max(0, Math.min(offset, Math.max(0, totalCount - 1)));
2534
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
2535
+ const currentPage = Math.floor(safeOffset / pageSize) + 1;
2536
+ // Slice: distribute offset across agent runs first, then bash runs
2459
2537
  const elements = [];
2538
+ // Page info
2539
+ if (totalPages > 1) {
2540
+ elements.push({
2541
+ tag: 'div',
2542
+ text: {
2543
+ tag: 'lark_md',
2544
+ content: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
2545
+ },
2546
+ });
2547
+ }
2548
+ let remaining = pageSize;
2549
+ let skipped = safeOffset;
2460
2550
  // Agent runs section
2461
- if (activeRuns.length > 0) {
2551
+ if (activeRuns.length > 0 && skipped < activeRuns.length && remaining > 0) {
2462
2552
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 🤖 Agent 任务' } });
2463
- for (const run of activeRuns) {
2553
+ const start = skipped;
2554
+ const end = Math.min(start + remaining, activeRuns.length);
2555
+ for (let i = start; i < end; i++) {
2556
+ const run = activeRuns[i];
2464
2557
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2465
2558
  elements.push({
2466
2559
  tag: 'div',
@@ -2483,11 +2576,19 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2483
2576
  });
2484
2577
  elements.push({ tag: 'hr' });
2485
2578
  }
2579
+ remaining -= end - start;
2580
+ skipped = 0;
2581
+ }
2582
+ else {
2583
+ skipped -= activeRuns.length;
2486
2584
  }
2487
2585
  // Bash runs section
2488
- if (activeBashRuns.length > 0) {
2586
+ if (activeBashRuns.length > 0 && skipped < activeBashRuns.length && remaining > 0) {
2489
2587
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 💻 Bash 命令' } });
2490
- for (const run of activeBashRuns) {
2588
+ const start = skipped;
2589
+ const end = Math.min(start + remaining, activeBashRuns.length);
2590
+ for (let i = start; i < end; i++) {
2591
+ const run = activeBashRuns[i];
2491
2592
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2492
2593
  elements.push({
2493
2594
  tag: 'div',
@@ -2508,6 +2609,38 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2508
2609
  elements.push({ tag: 'hr' });
2509
2610
  }
2510
2611
  }
2612
+ // Pagination buttons
2613
+ if (totalPages > 1) {
2614
+ const paginationButtons = [];
2615
+ if (currentPage > 1) {
2616
+ const prevOffset = (currentPage - 2) * pageSize;
2617
+ paginationButtons.push({
2618
+ tag: 'button',
2619
+ text: { tag: 'plain_text', content: '◀ 上一页' },
2620
+ type: 'default',
2621
+ size: 'small',
2622
+ behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: prevOffset } }],
2623
+ });
2624
+ }
2625
+ if (currentPage < totalPages) {
2626
+ const nextOffset = currentPage * pageSize;
2627
+ paginationButtons.push({
2628
+ tag: 'button',
2629
+ text: { tag: 'plain_text', content: '下一页 ▶' },
2630
+ type: 'primary',
2631
+ size: 'small',
2632
+ behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: nextOffset } }],
2633
+ });
2634
+ }
2635
+ elements.push({
2636
+ tag: 'column_set',
2637
+ columns: paginationButtons.map((btn) => ({
2638
+ tag: 'column',
2639
+ width: 'auto',
2640
+ elements: [btn],
2641
+ })),
2642
+ });
2643
+ }
2511
2644
  const card = {
2512
2645
  schema: '2.0',
2513
2646
  config: { wide_screen_mode: true },
@@ -2528,7 +2661,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2528
2661
  return { text: '重连失败,请检查网络和配置' };
2529
2662
  }
2530
2663
  }
2531
- cmdConfig(args, ctx) {
2664
+ async cmdConfig(args, ctx) {
2665
+ // Ensure probe cache is populated before building the card.
2666
+ await probeAllAgents();
2532
2667
  // Check if this is a direct set command: /config <key> <value>
2533
2668
  // Command-style set: immediate write to disk, clear pendingConfig
2534
2669
  if (args.length >= 2) {
@@ -2574,25 +2709,58 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2574
2709
  // 使用 pendingConfig(若存在)或当前 config
2575
2710
  const displayConfig = this.pendingConfig ?? this.config;
2576
2711
  // 定义字段分组:defaultAgent 选择器 + 该 agent 的配置字段 + idle.watchdogMinutes +
2577
- // output/logging。stopGraceMs / claude.binary 等实现细节走 YAML,不在卡片暴露。
2712
+ // output/logging。stopGraceMs 等实现细节走 YAML,不在卡片暴露。
2578
2713
  // 根据 defaultAgent 构建完整的 Agent 配置字段组
2579
2714
  // 使用 agent config builder 获取该 agent 的配置字段
2580
2715
  const defaultAgent = displayConfig.defaultAgent ?? 'claude';
2581
2716
  const configBuilder = getConfigBuilder(defaultAgent);
2582
- const agentConfigFields = configBuilder.buildFields(displayConfig);
2583
- // 使用共享渲染模块构建 tabs 和卡片
2584
- // 添加 defaultAgent 选择器作为第一个字段
2585
- agentConfigFields.unshift({
2586
- key: 'defaultAgent',
2587
- label: '默认 Agent',
2588
- type: 'select',
2589
- options: listRegisteredAgents(),
2717
+ // Build defaultAgent selector with availability annotations.
2718
+ // Direct inline construction (not via ConfigField) so that text (display label)
2719
+ // and value (agentKind for config storage) can differ — uninstalled agents get
2720
+ // a "⚠️ (未安装)" suffix in the label while the value stays clean.
2721
+ const allAgents = listRegisteredAgents();
2722
+ const agentOptions = allAgents.map((kind) => {
2723
+ const available = getCachedAvailability(kind);
2724
+ const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
2725
+ return { text: { tag: 'plain_text', content: label }, value: kind };
2590
2726
  });
2727
+ const selectedAgent = displayConfig.defaultAgent ?? 'claude';
2728
+ const agentSelector = {
2729
+ tag: 'column_set',
2730
+ flex_mode: 'none',
2731
+ columns: [
2732
+ {
2733
+ tag: 'column',
2734
+ width: 'weighted',
2735
+ weight: 2,
2736
+ vertical_align: 'center',
2737
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: '默认 Agent' } }],
2738
+ },
2739
+ {
2740
+ tag: 'column',
2741
+ width: 'weighted',
2742
+ weight: 3,
2743
+ vertical_align: 'center',
2744
+ elements: [
2745
+ {
2746
+ tag: 'select_static',
2747
+ placeholder: { tag: 'plain_text', content: '请选择' },
2748
+ options: agentOptions,
2749
+ initial_option: selectedAgent,
2750
+ behaviors: [{ type: 'callback', value: { cmd: 'config.set', key: 'defaultAgent' } }],
2751
+ },
2752
+ ],
2753
+ },
2754
+ ],
2755
+ };
2756
+ // Agent config fields — no longer include defaultAgent as a ConfigField
2757
+ // (it's rendered inline above with text/value separation).
2758
+ const agentConfigFieldsNoSelector = configBuilder.buildFields(displayConfig);
2591
2759
  const tabs = [
2592
2760
  {
2593
2761
  id: 'agent',
2594
2762
  label: `🤖 ${agentDisplayName(defaultAgent)}`,
2595
- fields: agentConfigFields,
2763
+ fields: agentConfigFieldsNoSelector,
2596
2764
  },
2597
2765
  {
2598
2766
  id: 'idle',
@@ -2623,11 +2791,30 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2623
2791
  ];
2624
2792
  // 使用共享渲染模块构建卡片
2625
2793
  const card = buildConfigCardFromTabs(tabs, displayConfig);
2794
+ // Inject the agent selector as the first element in the agent tab section,
2795
+ // right after the "**🤖 ..." header, so text/value can differ for uninstalled agents.
2796
+ // NOTE: findIndex relies on the agent tab header starting with "**🤖" — if the
2797
+ // tab label format changes, this fallback will fire and the selector moves to top.
2798
+ const body = card.body;
2799
+ if (body && Array.isArray(body.elements)) {
2800
+ const headerIdx = body.elements.findIndex((el) => typeof el === 'object' &&
2801
+ el !== null &&
2802
+ el.tag === 'div' &&
2803
+ el.text?.tag === 'lark_md' &&
2804
+ (el.text?.content ?? '').startsWith('**🤖'));
2805
+ if (headerIdx >= 0) {
2806
+ body.elements.splice(headerIdx + 1, 0, agentSelector);
2807
+ }
2808
+ else {
2809
+ getLogger().warn('[buildConfigCard] agent tab header not found, falling back to unshift');
2810
+ body.elements.unshift(agentSelector);
2811
+ }
2812
+ }
2626
2813
  return { card };
2627
2814
  }
2628
- cmdOrder(args, _ctx) {
2815
+ cmdOrder(args, _ctx, offset = 0) {
2629
2816
  const sub = args[0]?.toLowerCase();
2630
- // /order — list orders (simple CardKit 2.0 list, no tabs)
2817
+ // /order — list orders (CardKit 2.0 with pagination)
2631
2818
  if (!sub || sub === 'list') {
2632
2819
  this.orderStore.reload();
2633
2820
  const allOrders = this.orderStore.get();
@@ -2636,8 +2823,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2636
2823
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '暂无指令' } });
2637
2824
  }
2638
2825
  else {
2639
- for (let i = 0; i < allOrders.length; i++) {
2640
- const order = allOrders[i];
2826
+ // Pagination calculations
2827
+ const totalCount = allOrders.length;
2828
+ const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
2829
+ // Clamp offset to last page boundary to avoid empty pages
2830
+ const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
2831
+ const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
2832
+ const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
2833
+ const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
2834
+ const hasPagination = totalCount > ORDER_PAGE_SIZE;
2835
+ for (let i = 0; i < pageOrders.length; i++) {
2836
+ const order = pageOrders[i];
2641
2837
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
2642
2838
  // 文字在上方
2643
2839
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: displayText } });
@@ -2670,7 +2866,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2670
2866
  type: 'danger',
2671
2867
  size: 'small',
2672
2868
  behaviors: [
2673
- { type: 'callback', value: { cmd: 'order.delete', orderId: order.id } },
2869
+ {
2870
+ type: 'callback',
2871
+ value: { cmd: 'order.delete', orderId: order.id, offset: safeOffset },
2872
+ },
2674
2873
  ],
2675
2874
  },
2676
2875
  ],
@@ -2678,10 +2877,81 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2678
2877
  ],
2679
2878
  });
2680
2879
  // 非最后一项,添加分隔线
2681
- if (i < allOrders.length - 1) {
2880
+ if (i < pageOrders.length - 1) {
2682
2881
  elements.push({ tag: 'hr' });
2683
2882
  }
2684
2883
  }
2884
+ // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
2885
+ if (hasPagination) {
2886
+ const hasPrev = safeOffset > 0;
2887
+ const hasNext = safeOffset + ORDER_PAGE_SIZE < totalCount;
2888
+ const pageColumns = [];
2889
+ if (hasPrev) {
2890
+ pageColumns.push({
2891
+ tag: 'column',
2892
+ width: 'auto',
2893
+ vertical_align: 'center',
2894
+ elements: [
2895
+ {
2896
+ tag: 'button',
2897
+ text: { tag: 'plain_text', content: '⬅ 上一页' },
2898
+ type: 'default',
2899
+ size: 'small',
2900
+ behaviors: [
2901
+ {
2902
+ type: 'callback',
2903
+ value: {
2904
+ cmd: 'order.page',
2905
+ offset: safeOffset - ORDER_PAGE_SIZE,
2906
+ },
2907
+ },
2908
+ ],
2909
+ },
2910
+ ],
2911
+ });
2912
+ }
2913
+ pageColumns.push({
2914
+ tag: 'column',
2915
+ width: 'weighted',
2916
+ weight: 1,
2917
+ vertical_align: 'center',
2918
+ elements: [
2919
+ {
2920
+ tag: 'div',
2921
+ text: {
2922
+ tag: 'lark_md',
2923
+ content: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
2924
+ },
2925
+ },
2926
+ ],
2927
+ });
2928
+ if (hasNext) {
2929
+ pageColumns.push({
2930
+ tag: 'column',
2931
+ width: 'auto',
2932
+ vertical_align: 'center',
2933
+ elements: [
2934
+ {
2935
+ tag: 'button',
2936
+ text: { tag: 'plain_text', content: '下一页 ➡' },
2937
+ type: 'default',
2938
+ size: 'small',
2939
+ behaviors: [
2940
+ {
2941
+ type: 'callback',
2942
+ value: {
2943
+ cmd: 'order.page',
2944
+ offset: safeOffset + ORDER_PAGE_SIZE,
2945
+ },
2946
+ },
2947
+ ],
2948
+ },
2949
+ ],
2950
+ });
2951
+ }
2952
+ elements.push({ tag: 'hr' });
2953
+ elements.push({ tag: 'column_set', columns: pageColumns });
2954
+ }
2685
2955
  }
2686
2956
  return {
2687
2957
  card: {
@@ -5,6 +5,8 @@
5
5
  import type { ActiveRunSnapshot } from '../bridge/index.js';
6
6
  export interface SessionDisplayUsage {
7
7
  contextLength?: number;
8
+ /** 当前模型 context window 上限(仅 codex 提供);用于渲染 "Context - X (Y%)"。 */
9
+ contextLimit?: number;
8
10
  compactCount?: number;
9
11
  cacheReadTokens?: number;
10
12
  cacheCreationTokens?: number;
@@ -18,6 +20,7 @@ export declare function activeRunUsage(activeRun: ActiveRunSnapshot | undefined)
18
20
  */
19
21
  export declare function formatUsageStats(usage?: {
20
22
  contextLength?: number;
23
+ contextLimit?: number;
21
24
  compactCount?: number;
22
25
  cacheReadTokens?: number;
23
26
  cacheCreationTokens?: number;
@@ -14,10 +14,18 @@ export function activeRunUsage(activeRun) {
14
14
  }
15
15
  // --- Extracted helpers for refactoring ---
16
16
  /**
17
- * Format number to K unit string, rounding to the nearest integer K
18
- * (e.g., 120000 → 120K, 1500 → 2K, 1950 → 2K). Values < 1000 are returned as-is.
17
+ * Format number to human-readable unit string.
18
+ * - >= 1M: one decimal, M suffix (e.g., 1200000 → 1.2M, 25000000 → 25M)
19
+ * - >= 1K: rounded to nearest integer K (e.g., 1500 → 2K, 120000 → 120K)
20
+ * - < 1K: as-is (e.g., 500 → "500")
19
21
  */
20
22
  function formatTokenK(n) {
23
+ if (n >= 1_000_000) {
24
+ const m = n / 1_000_000;
25
+ const rounded = Math.round(m * 10) / 10;
26
+ // Drop unnecessary ".0" for whole numbers (2.0M → 2M)
27
+ return rounded === Math.floor(rounded) ? `${Math.floor(rounded)}M` : `${rounded}M`;
28
+ }
21
29
  if (n >= 1000) {
22
30
  const k = Math.round(n / 1000);
23
31
  return `${k}K`;
@@ -40,7 +48,11 @@ export function formatUsageStats(usage, options) {
40
48
  }
41
49
  // 2. Context 长度
42
50
  if (usage?.contextLength !== undefined) {
43
- lines.push(`Context - ${formatTokenK(usage.contextLength)}`);
51
+ const ctxLimit = usage.contextLimit;
52
+ const ctxPercent = ctxLimit !== undefined && ctxLimit > 0
53
+ ? ` (${Math.round((usage.contextLength / ctxLimit) * 100)}%)`
54
+ : '';
55
+ lines.push(`Context - ${formatTokenK(usage.contextLength)}${ctxPercent}`);
44
56
  }
45
57
  // 3. Compact 次数
46
58
  if (usage?.compactCount) {
@@ -12,7 +12,6 @@ export declare class ClaudeRunner extends SpawningRunner implements IAgentRunner
12
12
  private defaultEffort;
13
13
  private defaultSettings?;
14
14
  constructor(opts: {
15
- binary?: string;
16
15
  model?: string;
17
16
  effort?: string;
18
17
  settings?: string;
@@ -19,7 +19,6 @@ export class ClaudeRunner extends SpawningRunner {
19
19
  defaultSettings;
20
20
  constructor(opts) {
21
21
  super({
22
- binary: opts.binary ?? 'claude',
23
22
  pidDir: opts.pidDir,
24
23
  workspace: opts.workspace,
25
24
  stopGraceMs: opts.stopGraceMs,
@@ -27,6 +26,7 @@ export class ClaudeRunner extends SpawningRunner {
27
26
  pidFilePrefix: 'claude',
28
27
  logTag: 'claude-runner',
29
28
  });
29
+ this.binary = 'claude';
30
30
  this.defaultModel = opts.model ?? 'claude-opus-4-8';
31
31
  this.defaultEffort = opts.effort ?? 'medium';
32
32
  this.defaultSettings = opts.settings;
@@ -26,8 +26,6 @@ import type { AgentKind, AgentSessionReader, AgentEvent, AgentRunner, AgentStatu
26
26
  import type { SpawnOptions } from '../types.js';
27
27
  import { SpawningRunner, type RunnerTranslator } from '../common/spawning-runner.js';
28
28
  interface CodexExecRunnerOptions {
29
- /** Path to codex binary. Default: 'codex' (in PATH). */
30
- binary?: string;
31
29
  /** Model override. Omitted → codex reads from its config.toml. */
32
30
  model?: string;
33
31
  /** Model provider override. Omitted → codex reads from its config.toml. */