lark-remote 0.5.6 → 0.5.8

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 (85) hide show
  1. package/CHANGELOG.md +46 -6
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/index.d.ts +63 -1
  5. package/dist/bridge/index.js +147 -14
  6. package/dist/bridge/queue-manager.js +41 -58
  7. package/dist/card/approval-render.js +2 -1
  8. package/dist/card/bash-card-session.d.ts +0 -1
  9. package/dist/card/bash-card-session.js +0 -8
  10. package/dist/card/bash-renderer.js +2 -1
  11. package/dist/card/card-budget.d.ts +37 -0
  12. package/dist/card/card-budget.js +143 -24
  13. package/dist/card/card-session.d.ts +3 -13
  14. package/dist/card/card-session.js +4 -18
  15. package/dist/card/card-shared.d.ts +11 -0
  16. package/dist/card/card-shared.js +19 -13
  17. package/dist/card/run-card-session.d.ts +0 -1
  18. package/dist/card/run-card-session.js +0 -6
  19. package/dist/card/run-renderer.d.ts +18 -0
  20. package/dist/card/run-renderer.js +330 -62
  21. package/dist/card/run-state.d.ts +26 -0
  22. package/dist/card/run-state.js +45 -0
  23. package/dist/card/text-truncate.d.ts +3 -5
  24. package/dist/card/text-truncate.js +3 -5
  25. package/dist/card/time.d.ts +14 -0
  26. package/dist/card/time.js +30 -0
  27. package/dist/card/tool-render.d.ts +15 -2
  28. package/dist/card/tool-render.js +42 -82
  29. package/dist/common/text-width.d.ts +24 -0
  30. package/dist/common/text-width.js +93 -0
  31. package/dist/common/tool-summary.d.ts +19 -0
  32. package/dist/common/tool-summary.js +171 -0
  33. package/dist/common/turn-failure.d.ts +13 -0
  34. package/dist/common/turn-failure.js +18 -0
  35. package/dist/config/index.d.ts +1 -1
  36. package/dist/config/index.js +1 -1
  37. package/dist/connector/index.js +1 -1
  38. package/dist/index.js +66 -13
  39. package/dist/order/index.d.ts +2 -0
  40. package/dist/order/index.js +2 -0
  41. package/dist/platform/command.js +1 -1
  42. package/dist/platform/shell.d.ts +2 -2
  43. package/dist/platform/spawn.d.ts +1 -1
  44. package/dist/platform/spawn.js +2 -2
  45. package/dist/platform/types.d.ts +1 -1
  46. package/dist/router/card-helpers.d.ts +18 -16
  47. package/dist/router/card-helpers.js +88 -46
  48. package/dist/router/config/codex.js +2 -3
  49. package/dist/router/config/common/model-patch.d.ts +8 -0
  50. package/dist/router/config/common/model-patch.js +13 -0
  51. package/dist/router/config/common/render.js +30 -77
  52. package/dist/router/config/dsh.js +2 -1
  53. package/dist/router/config/kimi.js +3 -3
  54. package/dist/router/index.d.ts +9 -11
  55. package/dist/router/index.js +165 -356
  56. package/dist/router/order-exec-dispatch.js +2 -1
  57. package/dist/runner/claude/session.d.ts +1 -1
  58. package/dist/runner/claude/session.js +3 -3
  59. package/dist/runner/common/defaults.d.ts +2 -0
  60. package/dist/runner/common/defaults.js +3 -0
  61. package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
  62. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  63. package/dist/runner/common/jsonrpc/transport.js +3 -3
  64. package/dist/runner/common/spawning-runner.d.ts +1 -1
  65. package/dist/runner/common/spawning-runner.js +2 -2
  66. package/dist/runner/dsh/auth.js +2 -1
  67. package/dist/runner/dsh/client.d.ts +100 -3
  68. package/dist/runner/dsh/client.js +290 -6
  69. package/dist/runner/dsh/exit-cancel.d.ts +32 -0
  70. package/dist/runner/dsh/exit-cancel.js +72 -0
  71. package/dist/runner/dsh/inflight.d.ts +62 -0
  72. package/dist/runner/dsh/inflight.js +140 -0
  73. package/dist/runner/dsh/runner.d.ts +90 -4
  74. package/dist/runner/dsh/runner.js +436 -18
  75. package/dist/runner/dsh/translator.d.ts +1 -1
  76. package/dist/runner/dsh/translator.js +103 -1
  77. package/dist/runner/dsh/types.d.ts +60 -0
  78. package/dist/runner/types.d.ts +95 -2
  79. package/dist/runner/types.js +9 -0
  80. package/dist/session/claude/session-index.d.ts +0 -4
  81. package/dist/session/claude/session-index.js +0 -8
  82. package/dist/session/claude/sessions.js +2 -2
  83. package/dist/session/dsh/sessions.js +157 -17
  84. package/dist/update/install.js +1 -1
  85. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgent
6
6
  import { syncAgentChoices } from '../runner/index.js';
7
7
  import { resolveDshCredentialsFile } from '../runner/dsh/auth.js';
8
8
  import { WorkspaceStore } from '../workspace/index.js';
9
- import { OrderStore } from '../order/index.js';
9
+ import { OrderStore, ORDER_NOT_FOUND_TEXT } from '../order/index.js';
10
10
  import { resolveAlias } from '../order/alias-resolve.js';
11
11
  import { diffConfig, setNestedValue } from './config-diff.js';
12
12
  import { AGENT_KINDS } from '../runner/types.js';
@@ -16,7 +16,7 @@ import { clampInt } from '../common/clamp.js';
16
16
  import { activeRunUsage } from './usage-format.js';
17
17
  import { markdownDiv, buildSessionHistoryCard, paginationBar, searchBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
18
18
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
19
- import { newSessionButton, resumeCompactButton, agentDisplayName } from '../card/card-shared.js';
19
+ import { cardButton, newSessionButton, resumeCompactButton, agentDisplayName, } from '../card/card-shared.js';
20
20
  import { displayName } from '../platform/path.js';
21
21
  import { atomicWrite } from '../persistence/atomic-write.js';
22
22
  import { checkLatestVersion as defaultCheckLatestVersion, compareVersions as defaultCompareVersions, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
@@ -133,6 +133,18 @@ const RESUME_CONTENT_PREFETCH = 5;
133
133
  * `New Session`、空串),都不是真实输入,不得渲染成行标题。
134
134
  */
135
135
  const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
136
+ /** 列表卡「最近输入 / 最后输出」单行预览的长度上限(中文按字符,手机约两行)。 */
137
+ const RESUME_PREVIEW_MAX = 60;
138
+ /**
139
+ * 列表卡单行预览:压平空白、去掉反引号,再按字符截断。
140
+ *
141
+ * 为什么去反引号:agent 的输出里 `code` 很常见,截断点落在一对反引号中间时
142
+ * 后半张卡片的 markdown 会被当成代码块渲染(lark_md 不闭合就不回退)。
143
+ */
144
+ function sessionPreviewLine(text, max) {
145
+ const flat = text.replace(/`/g, '').replace(/\s+/g, ' ').trim();
146
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
147
+ }
136
148
  /**
137
149
  * Card-action commands that must run with enqueue({ immediate: true }).
138
150
  * These bypass the serial queue and execute immediately.
@@ -646,6 +658,9 @@ export class CommandRouter {
646
658
  case 'stop':
647
659
  await this.handleCardStop(value, ctx);
648
660
  return;
661
+ case 'job.kill':
662
+ await this.handleJobKill(value, ctx);
663
+ return;
649
664
  case 'queue.cancel':
650
665
  // 撤销:从队列中删除这个消息
651
666
  await this.handleQueueCancel(value, ctx);
@@ -832,12 +847,17 @@ export class CommandRouter {
832
847
  // 2. Stop current running process
833
848
  // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
834
849
  // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
835
- const stopped = await this.bridge.interruptCurrentRun({
850
+ const interrupt = await this.bridge.interruptCurrentRunDetailed({
836
851
  userId: ctx.userId,
837
852
  chatId: ctx.chatId,
838
853
  cwd,
839
854
  });
840
- getLogger().info(`[router] queue.immediate: stopped=${stopped}`);
855
+ const stopped = interrupt.stopped;
856
+ // A remote (DSH) stop is only a request; when it cannot be confirmed the
857
+ // target must not be promised "immediate" execution — its prompt would sit
858
+ // behind the still-running turn with no visible progress.
859
+ const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
860
+ getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
841
861
  // 3. Remove all tasks BEFORE this one (by position in the current queue).
842
862
  // The target may have begun while interruptCurrentRun was awaiting (the chain
843
863
  // advances concurrently): then it is already out of the snapshot and every
@@ -910,7 +930,14 @@ export class CommandRouter {
910
930
  return;
911
931
  }
912
932
  const workspaceBusy = this.bridge.isBusyFor(cwd);
913
- if (!workspaceBusy) {
933
+ if (stopUnconfirmed) {
934
+ const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
935
+ await this.bridge.sendResult({
936
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
937
+ `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
938
+ }, ctx);
939
+ }
940
+ else if (!workspaceBusy) {
914
941
  // Normal path: nothing new began during the stop, the target is next.
915
942
  // Mark its own card as executing now so buttons grey out immediately.
916
943
  // Done BEFORE any removal so getQueuedTask still returns the task with
@@ -1351,17 +1378,43 @@ export class CommandRouter {
1351
1378
  await this.bridge.sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, ctx);
1352
1379
  return;
1353
1380
  }
1354
- const stopped = await this.bridge.interruptCurrentRun({
1381
+ const interrupt = await this.bridge.interruptCurrentRunDetailed({
1355
1382
  userId: ctx.userId,
1356
1383
  chatId: ctx.chatId,
1357
1384
  runId: value.runId,
1358
1385
  });
1386
+ const stopped = interrupt.stopped;
1359
1387
  // Regression 2026-06-22: card stop button silently no-op'd when the
1360
1388
  // run had already exited. Give the user a visible result.
1361
1389
  if (!stopped) {
1362
1390
  await this.bridge.sendResult({ text: '该任务已结束,无需终止' }, ctx);
1391
+ return;
1392
+ }
1393
+ // DSH: `session/cancel` is only a request. If the host still reports the
1394
+ // turn open, say so instead of letting the card imply a clean stop.
1395
+ if (interrupt.stop?.status === 'unconfirmed') {
1396
+ const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
1397
+ await this.bridge.sendResult({
1398
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
1399
+ '请在 DSH Web UI 检查;需要终止时再点一次停止。',
1400
+ }, ctx);
1363
1401
  }
1364
1402
  }
1403
+ /**
1404
+ * Handle a terminal card's 「停止」 on one background job.
1405
+ *
1406
+ * 任务跨 run 生命周期,所以卡片按钮是唯一入口;失败必须给出可读原因
1407
+ * (任务已自行结束、服务重启后快照被回收),不能点了没反应。
1408
+ */
1409
+ async handleJobKill(value, ctx) {
1410
+ const runId = typeof value.runId === 'string' ? value.runId : '';
1411
+ const jobId = typeof value.jobId === 'string' ? value.jobId : '';
1412
+ if (!runId || !jobId)
1413
+ return;
1414
+ if (await this.bridge.killBackgroundJob(runId, jobId, ctx))
1415
+ return;
1416
+ await this.bridge.sendResult({ text: '停止失败:任务可能已自行结束,或记录已回收' }, ctx);
1417
+ }
1365
1418
  /**
1366
1419
  * Resolve an order's text at the enqueue boundary so order.exec can enter
1367
1420
  * the serial queue as an equivalent user message (Plan A) — the same path a
@@ -1463,7 +1516,7 @@ export class CommandRouter {
1463
1516
  return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
1464
1517
  }
1465
1518
  if (!this.orderStore.has(orderId)) {
1466
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1519
+ return { toast: { type: 'error', content: ORDER_NOT_FOUND_TEXT } };
1467
1520
  }
1468
1521
  // Delete the order
1469
1522
  this.orderStore.remove(orderId);
@@ -1501,7 +1554,7 @@ export class CommandRouter {
1501
1554
  this.orderStore.reload();
1502
1555
  const order = this.orderStore.get().find((o) => o.id === orderId);
1503
1556
  if (!order) {
1504
- return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1557
+ return { toast: { type: 'error', content: ORDER_NOT_FOUND_TEXT } };
1505
1558
  }
1506
1559
  // 列表卡显示的是截断版(≤100 字符 + ...),编辑卡预览保持一致
1507
1560
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
@@ -1573,7 +1626,7 @@ export class CommandRouter {
1573
1626
  */
1574
1627
  async handleOrderAliasInput(value, ctx) {
1575
1628
  const name = value.inputValue ?? value.formValue?.['aliasName'];
1576
- return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
1629
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: ORDER_NOT_FOUND_TEXT }, (order) => {
1577
1630
  const trimmed = name?.trim() ?? '';
1578
1631
  // 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
1579
1632
  // 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
@@ -1635,7 +1688,7 @@ export class CommandRouter {
1635
1688
  */
1636
1689
  async handleOrderTextInput(value, ctx) {
1637
1690
  const raw = value.inputValue ?? value.formValue?.['text'];
1638
- return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
1691
+ return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: ORDER_NOT_FOUND_TEXT }, (order) => {
1639
1692
  let updated;
1640
1693
  try {
1641
1694
  updated = this.orderStore.updateText(order.id, raw ?? '');
@@ -1645,7 +1698,7 @@ export class CommandRouter {
1645
1698
  }
1646
1699
  if (!updated) {
1647
1700
  // reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
1648
- return { ok: false, error: '指令不存在或已被删除' };
1701
+ return { ok: false, error: ORDER_NOT_FOUND_TEXT };
1649
1702
  }
1650
1703
  const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
1651
1704
  return { ok: true, toast: `✅ 已更新指令: ${preview}` };
@@ -2086,7 +2139,7 @@ export class CommandRouter {
2086
2139
  switchSessionId = '';
2087
2140
  }
2088
2141
  }
2089
- // DSH preset 变更 = 下次 run 新建 session(§4.3):
2142
+ // DSH preset 变更 = 下次 run 新建 session:
2090
2143
  // preset 在 session 创建时固定,中途切换会被服务端拒绝(agent-preset-conflict)。
2091
2144
  // 旧 sessionId 存入 previousSessions 停车位(可手动 /resume,但 /resume 会做
2092
2145
  // preset 一致性校验),当前 sessionId 清空——下次 run 走 session.create 新建。
@@ -2281,13 +2334,7 @@ export class CommandRouter {
2281
2334
  width: 'weighted',
2282
2335
  weight: 2,
2283
2336
  elements: [
2284
- {
2285
- tag: 'button',
2286
- text: { tag: 'plain_text', content: c.label },
2287
- type: 'default',
2288
- size: 'small',
2289
- behaviors: [{ type: 'callback', value: { cmd: `help.${c.cmd}` } }],
2290
- },
2337
+ cardButton(c.label, { cmd: `help.${c.cmd}` }, { type: 'default', size: 'small' }),
2291
2338
  ],
2292
2339
  },
2293
2340
  {
@@ -2431,11 +2478,20 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2431
2478
  };
2432
2479
  }
2433
2480
  async cmdStop(ctx) {
2434
- const stopped = await this.bridge.interruptCurrentRun({
2481
+ const interrupt = await this.bridge.interruptCurrentRunDetailed({
2435
2482
  userId: ctx.userId,
2436
2483
  chatId: ctx.chatId,
2437
2484
  });
2438
- return stopped ? null : { text: '当前没有运行中的进程' };
2485
+ if (!interrupt.stopped)
2486
+ return { text: '当前没有运行中的进程' };
2487
+ if (interrupt.stop?.status === 'unconfirmed') {
2488
+ const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
2489
+ return {
2490
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
2491
+ '请在 DSH Web UI 检查;需要终止时再发一次 /stop。',
2492
+ };
2493
+ }
2494
+ return null;
2439
2495
  }
2440
2496
  async cmdExit() {
2441
2497
  // Signal handle() to invoke exitHandler after the reply is sent.
@@ -2754,17 +2810,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2754
2810
  buildResumeActionButtons(opts) {
2755
2811
  const actions = [];
2756
2812
  if (opts.isActive) {
2757
- actions.push({
2758
- tag: 'button',
2759
- text: { tag: 'plain_text', content: '⏹ 终止' },
2760
- type: 'danger',
2761
- behaviors: [
2762
- {
2763
- type: 'callback',
2764
- value: { cmd: 'stop', runId: opts.activeRunRunId, cwd: opts.cwd },
2765
- },
2766
- ],
2767
- });
2813
+ actions.push(cardButton('⏹ 终止', { cmd: 'stop', runId: opts.activeRunRunId, cwd: opts.cwd }, { type: 'danger' }));
2768
2814
  }
2769
2815
  if (opts.compact && !opts.isActive) {
2770
2816
  actions.push(resumeCompactButton(opts.compact.sessionId, opts.compact.agentKind));
@@ -2880,88 +2926,32 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2880
2926
  // Build header with parent button
2881
2927
  const headerElements = [];
2882
2928
  if (hasParent) {
2883
- headerElements.push({
2884
- tag: 'button',
2885
- text: { tag: 'plain_text', content: '上级' },
2886
- type: 'default',
2887
- size: 'small',
2888
- behaviors: [
2889
- { type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: browseRoot } },
2890
- ],
2891
- });
2929
+ headerElements.push(cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: browseRoot }, { type: 'default', size: 'small' }));
2892
2930
  }
2893
2931
  // Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
2894
2932
  if (canReturn) {
2895
- headerElements.push({
2896
- tag: 'button',
2897
- text: { tag: 'plain_text', content: '返回' },
2898
- type: 'default',
2899
- size: 'small',
2900
- behaviors: [
2901
- { type: 'callback', value: { cmd: 'ls.browse', path: browseRoot, root: browseRoot } },
2902
- ],
2903
- });
2933
+ headerElements.push(cardButton('返回', { cmd: 'ls.browse', path: browseRoot, root: browseRoot }, { type: 'default', size: 'small' }));
2904
2934
  }
2905
- headerElements.push({
2906
- tag: 'button',
2907
- text: { tag: 'plain_text', content: '刷新' },
2908
- type: 'default',
2909
- size: 'small',
2910
- behaviors: [
2911
- {
2912
- type: 'callback',
2913
- value: {
2914
- cmd: 'ls.refresh',
2915
- path: targetDir,
2916
- offset: safeOffset,
2917
- root: browseRoot,
2918
- ...(filter ? { q: filter } : {}),
2919
- },
2920
- },
2921
- ],
2922
- });
2935
+ headerElements.push(cardButton('刷新', {
2936
+ cmd: 'ls.refresh',
2937
+ path: targetDir,
2938
+ offset: safeOffset,
2939
+ root: browseRoot,
2940
+ ...(filter ? { q: filter } : {}),
2941
+ }, { type: 'default', size: 'small' }));
2923
2942
  // Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
2924
2943
  if (isSubdir) {
2925
- headerElements.push({
2926
- tag: 'button',
2927
- text: { tag: 'plain_text', content: '切换' },
2928
- type: 'primary',
2929
- size: 'small',
2930
- // 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
2931
- // 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
2932
- behaviors: [
2933
- { type: 'callback', value: { cmd: 'ls.switch', path: targetDir, root: browseRoot } },
2934
- ],
2935
- });
2944
+ headerElements.push(
2945
+ // 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
2946
+ // 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
2947
+ cardButton('切换', { cmd: 'ls.switch', path: targetDir, root: browseRoot }, { type: 'primary', size: 'small' }));
2936
2948
  }
2937
2949
  // Helper to create directory button - use targetDir
2938
- const dirButton = (name) => ({
2939
- tag: 'button',
2940
- text: { tag: 'plain_text', content: `📁 ${name}` },
2941
- type: 'default',
2942
- behaviors: [
2943
- {
2944
- type: 'callback',
2945
- value: {
2946
- cmd: 'ls.browse',
2947
- path: path.join(targetDir, name),
2948
- root: browseRoot,
2949
- },
2950
- },
2951
- ],
2952
- });
2950
+ const dirButton = (name) => cardButton(`📁 ${name}`, { cmd: 'ls.browse', path: path.join(targetDir, name), root: browseRoot }, { type: 'default' });
2953
2951
  // Helper to create file button with size - use targetDir
2954
2952
  const fileButton = (name, size) => {
2955
2953
  const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
2956
- return {
2957
- tag: 'button',
2958
- text: { tag: 'plain_text', content: `📄 ${name}${sizeStr}` },
2959
- type: 'default',
2960
- size: 'small',
2961
- behaviors: [
2962
- { type: 'callback', value: { cmd: 'ls.file', path: path.join(targetDir, name) } },
2963
- ],
2964
- };
2954
+ return cardButton(`📄 ${name}${sizeStr}`, { cmd: 'ls.file', path: path.join(targetDir, name) }, { type: 'default', size: 'small' });
2965
2955
  };
2966
2956
  // Build each section's elements (no tabs - use section headers)
2967
2957
  // Note: div+elements is not supported in CardKit 2.0, use column_set instead
@@ -3126,25 +3116,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3126
3116
  const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
3127
3117
  const parentDir = path.dirname(filePath);
3128
3118
  const buttons = [
3129
- {
3130
- tag: 'button',
3131
- text: { tag: 'plain_text', content: '📎 下载' },
3132
- type: 'primary',
3133
- size: 'small',
3134
- behaviors: [{ type: 'callback', value: { cmd: 'ls.file', path: filePath } }],
3135
- },
3119
+ cardButton('📎 下载', { cmd: 'ls.file', path: filePath }, { type: 'primary', size: 'small' }),
3136
3120
  ];
3137
3121
  if (parentDir !== filePath) {
3138
- buttons.push({
3139
- tag: 'button',
3140
- text: { tag: 'plain_text', content: '上级' },
3141
- type: 'default',
3142
- size: 'small',
3143
- // 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 工作目录
3144
- behaviors: [
3145
- { type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: parentDir } },
3146
- ],
3147
- });
3122
+ buttons.push(
3123
+ // 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 工作目录
3124
+ cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: parentDir }, { type: 'default', size: 'small' }));
3148
3125
  }
3149
3126
  return {
3150
3127
  card: {
@@ -3323,18 +3300,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3323
3300
  tag: 'column',
3324
3301
  width: 'auto',
3325
3302
  elements: [
3326
- {
3327
- tag: 'button',
3328
- text: { tag: 'plain_text', content: `切换为 ${switchLabel}` },
3329
- type: 'default',
3330
- size: 'small',
3331
- behaviors: [
3332
- {
3333
- type: 'callback',
3334
- value: { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) },
3335
- },
3336
- ],
3337
- },
3303
+ cardButton(`切换为 ${switchLabel}`, { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) }, { type: 'default', size: 'small' }),
3338
3304
  ],
3339
3305
  },
3340
3306
  ],
@@ -3353,46 +3319,24 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3353
3319
  tag: 'column',
3354
3320
  width: 'auto',
3355
3321
  elements: [
3356
- {
3357
- tag: 'button',
3358
- text: { tag: 'plain_text', content: '切换' },
3359
- type: 'primary',
3360
- size: 'small',
3361
- behaviors: [
3362
- {
3363
- type: 'callback',
3364
- value: {
3365
- cmd: 'ws.use',
3366
- name: entry.name,
3367
- offset: safeOffset,
3368
- ...(filter ? { q: filter } : {}),
3369
- },
3370
- },
3371
- ],
3372
- },
3322
+ cardButton('切换', {
3323
+ cmd: 'ws.use',
3324
+ name: entry.name,
3325
+ offset: safeOffset,
3326
+ ...(filter ? { q: filter } : {}),
3327
+ }, { type: 'primary', size: 'small' }),
3373
3328
  ],
3374
3329
  },
3375
3330
  {
3376
3331
  tag: 'column',
3377
3332
  width: 'auto',
3378
3333
  elements: [
3379
- {
3380
- tag: 'button',
3381
- text: { tag: 'plain_text', content: '删除' },
3382
- type: 'danger',
3383
- size: 'small',
3384
- behaviors: [
3385
- {
3386
- type: 'callback',
3387
- value: {
3388
- cmd: 'ws.remove',
3389
- name: entry.name,
3390
- offset: safeOffset,
3391
- ...(filter ? { q: filter } : {}),
3392
- },
3393
- },
3394
- ],
3395
- },
3334
+ cardButton('删除', {
3335
+ cmd: 'ws.remove',
3336
+ name: entry.name,
3337
+ offset: safeOffset,
3338
+ ...(filter ? { q: filter } : {}),
3339
+ }, { type: 'danger', size: 'small' }),
3396
3340
  ],
3397
3341
  },
3398
3342
  ],
@@ -3427,17 +3371,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3427
3371
  }
3428
3372
  }
3429
3373
  }
3430
- /**
3431
- * degraded card when a session's export is non-empty but unparseable
3432
- * (truncated by opencode's pipe cap on huge sessions, or otherwise corrupt).
3433
- * The session exists, so reuse listSessions metadata (title) instead of the
3434
- * misleading "未找到". Reuses the same card-builder helpers (markdownDiv) as
3435
- * the normal resume card; enforceCardBudget still applies at send time.
3436
- */
3437
- /**
3438
- * /resume 核心实现(public:handleResumePage / handle / resume.use 共用,
3439
- * 也是测试的直接 seam,避免测试用 @ts-expect-error 摸私有方法)。
3440
- */
3441
3374
  /**
3442
3375
  * DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
3443
3376
  *
@@ -3604,6 +3537,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3604
3537
  const sessionMeta = new Map();
3605
3538
  const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
3606
3539
  for (const s of allSessions.slice(0, prefetchCount)) {
3540
+ // 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
3541
+ // DSH 的 readSessionContent 是 3 个 curl 加一次 maxMessages=10000 的
3542
+ // session/page;一页 5 个会话按行预取等于 15 次同步 HTTP,而这些信息
3543
+ // session/list 已经给全了。
3544
+ if (s.title || s.lastUserMessage || s.lastAssistantMessage)
3545
+ continue;
3607
3546
  // 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
3608
3547
  // readSessionSummary,避免对前 N 条各做一次全量 JSONL 解析来构造
3609
3548
  // 立即被丢弃的 events 数组。其他 agent 无此方法时回退全量读取再取字段。
@@ -3647,26 +3586,33 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3647
3586
  const buttonText = isCurrent ? '✓ 当前会话' : '恢复此会话';
3648
3587
  const meta = sessionMeta.get(s.sessionId);
3649
3588
  // Title priority:
3650
- // 1. Prefetched displayTitle (aiTitle or last user message) keeps its
3589
+ // 1. 行级 title(服务端投影,DSH)→ 「标题」。
3590
+ // 2. Prefetched displayTitle (aiTitle or last user message) keeps its
3651
3591
  // original label (AI 标题 / 最近输入).
3652
- // 2. Otherwise the lightweight summary renders under the neutral
3592
+ // 3. Otherwise the lightweight summary renders under the neutral
3653
3593
  // "会话摘要" label — summary is the first user message, not the
3654
3594
  // last input, so "最近输入" would be semantically wrong.
3655
- // 3. Placeholder summaries (no user message) render no title section
3595
+ // 4. Placeholder summaries (no user message) render no title section
3656
3596
  // at all — never leak '(无摘要)'/'(no user message)'/'New Session'.
3657
3597
  const summaryIsPlaceholder = RESUME_SUMMARY_PLACEHOLDERS.has(s.summary.trim());
3658
3598
  const prefetchedTitle = meta?.displayTitle;
3599
+ // 行级 title 也过占位符过滤:DSH 对空会话可能给 "New Session",旧路径
3600
+ // 的 summary 会被挡掉,新路径如果不过滤就会把它当真实标题渲染。
3601
+ const rowTitleRaw = s.title?.trim() ?? '';
3602
+ const rowTitle = rowTitleRaw && !RESUME_SUMMARY_PLACEHOLDERS.has(rowTitleRaw) ? rowTitleRaw : undefined;
3659
3603
  // aiTitle 不能单独作为"预取标题"键:若 displayTitle 缺失(理论上 opencode
3660
3604
  // info.title 非空但 export 无 user text),标题兜底是 summary,label 必须
3661
3605
  // 是中性"会话摘要"而不是"最近输入"(Review 边缘分支)。
3662
- const titleText = prefetchedTitle ?? (summaryIsPlaceholder ? '' : s.summary);
3606
+ const titleText = prefetchedTitle ?? rowTitle ?? (summaryIsPlaceholder ? '' : s.summary);
3663
3607
  const titleLabel = prefetchedTitle
3664
3608
  ? meta?.aiTitle
3665
3609
  ? 'AI 标题'
3666
3610
  : '最近输入'
3667
- : summaryIsPlaceholder
3668
- ? ''
3669
- : '会话摘要';
3611
+ : rowTitle
3612
+ ? '标题'
3613
+ : summaryIsPlaceholder
3614
+ ? ''
3615
+ : '会话摘要';
3670
3616
  const sections = [];
3671
3617
  if (titleText) {
3672
3618
  sections.push(`🏷️ **${titleLabel}**\n${titleText.slice(0, 80)}${titleText.length > 80 ? '...' : ''}`);
@@ -3674,6 +3620,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3674
3620
  if (meta?.recap) {
3675
3621
  sections.push(`📝 **Recap**\n${meta.recap.slice(0, 60)}${meta.recap.length > 60 ? '...' : ''}`);
3676
3622
  }
3623
+ // 行级预览(DSH 服务端投影):最后一句我说了什么 / 最后一句它答了什么。
3624
+ // 只对提供了字段的 reader 渲染,其他 agent 的卡片一个字都不变。
3625
+ if (s.lastUserMessage) {
3626
+ sections.push(`✏️ **最近输入**\n${sessionPreviewLine(s.lastUserMessage, RESUME_PREVIEW_MAX)}`);
3627
+ }
3628
+ if (s.lastAssistantMessage) {
3629
+ sections.push(`📤 **最后输出**\n${sessionPreviewLine(s.lastAssistantMessage, RESUME_PREVIEW_MAX)}`);
3630
+ }
3677
3631
  const infoStr = sections.length > 0 ? '\n' + sections.join('\n──\n') : '';
3678
3632
  elements.push({ tag: 'hr' });
3679
3633
  // Both current and non-current sessions get a button for visual consistency
@@ -3691,18 +3645,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3691
3645
  tag: 'column',
3692
3646
  width: 'auto',
3693
3647
  elements: [
3694
- {
3695
- tag: 'button',
3696
- text: { tag: 'plain_text', content: buttonText },
3697
- type: isCurrent ? 'default' : 'primary',
3698
- disabled: isCurrent,
3699
- behaviors: [
3700
- {
3701
- type: 'callback',
3702
- value: { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind },
3703
- },
3704
- ],
3705
- },
3648
+ cardButton(buttonText, { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind }, { type: isCurrent ? 'default' : 'primary', disabled: isCurrent }),
3706
3649
  ],
3707
3650
  },
3708
3651
  ],
@@ -3780,14 +3723,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3780
3723
  content: `> session: ${run.sessionId.slice(0, 12)}... \n> 状态: ${statusLabel}`,
3781
3724
  },
3782
3725
  });
3783
- // Stop button - CardKit 2.0 with behaviors callback
3784
- elements.push({
3785
- tag: 'button',
3786
- text: { tag: 'plain_text', content: '⏹ 停止' },
3787
- type: 'danger',
3788
- size: 'small',
3789
- behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: run.runId, cwd: run.cwd } }],
3790
- });
3726
+ elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
3791
3727
  elements.push({ tag: 'hr' });
3792
3728
  }
3793
3729
  remaining -= end - start;
@@ -3812,14 +3748,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3812
3748
  tag: 'div',
3813
3749
  text: { tag: 'lark_md', content: `> 目录: ${run.cwd} \n> 状态: ${statusLabel}` },
3814
3750
  });
3815
- // Stop button - CardKit 2.0 with behaviors callback
3816
- elements.push({
3817
- tag: 'button',
3818
- text: { tag: 'plain_text', content: '⏹ 停止' },
3819
- type: 'danger',
3820
- size: 'small',
3821
- behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: run.runId, cwd: run.cwd } }],
3822
- });
3751
+ elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
3823
3752
  elements.push({ tag: 'hr' });
3824
3753
  }
3825
3754
  }
@@ -3926,55 +3855,29 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3926
3855
  // 使用 agent config builder 获取该 agent 的配置字段
3927
3856
  const defaultAgent = displayConfig.defaultAgent ?? 'claude';
3928
3857
  const configBuilder = getConfigBuilder(defaultAgent);
3929
- // Build defaultAgent selector with availability annotations.
3930
- // Direct inline construction (not via ConfigField) so that text (display label)
3931
- // and value (agentKind for config storage) can differ — uninstalled agents get
3932
- // a "⚠️ (未安装)" suffix in the label while the value stays clean.
3933
- // 固定展示顺序(Codex → Claude → OpenCode → Pi → Kimi),
3858
+ // defaultAgent 选择器作为普通 select 字段交给共享渲染(SelectOption 支持
3859
+ // text/value 分离:未安装 agent 的 label 带「⚠️ (未安装)」,value 保持干净)。
3860
+ // 固定展示顺序(Codex → Claude → OpenCode → Pi → Kimi → DSH),
3934
3861
  // 明确未安装的 agent 排到后面(getCachedAvailability === false)。
3935
3862
  const allAgents = sortAgentsForDisplay(listRegisteredAgents(), getCachedAvailability);
3936
3863
  const agentOptions = allAgents.map((kind) => {
3937
3864
  const available = getCachedAvailability(kind);
3938
3865
  const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
3939
- return { text: { tag: 'plain_text', content: label }, value: kind };
3866
+ return { text: label, value: kind };
3940
3867
  });
3941
3868
  const selectedAgent = displayConfig.defaultAgent ?? 'claude';
3942
- const agentSelector = {
3943
- tag: 'column_set',
3944
- flex_mode: 'none',
3945
- columns: [
3946
- {
3947
- tag: 'column',
3948
- width: 'weighted',
3949
- weight: 2,
3950
- vertical_align: 'center',
3951
- elements: [{ tag: 'div', text: { tag: 'lark_md', content: '默认 Agent' } }],
3952
- },
3953
- {
3954
- tag: 'column',
3955
- width: 'weighted',
3956
- weight: 3,
3957
- vertical_align: 'center',
3958
- elements: [
3959
- {
3960
- tag: 'select_static',
3961
- placeholder: { tag: 'plain_text', content: '请选择' },
3962
- options: agentOptions,
3963
- initial_option: selectedAgent,
3964
- behaviors: [{ type: 'callback', value: { cmd: 'config.set', key: 'defaultAgent' } }],
3965
- },
3966
- ],
3967
- },
3968
- ],
3869
+ const agentSelectorField = {
3870
+ key: 'defaultAgent',
3871
+ label: '默认 Agent',
3872
+ type: 'select',
3873
+ options: agentOptions,
3874
+ currentValue: selectedAgent,
3969
3875
  };
3970
- // Agent config fields — no longer include defaultAgent as a ConfigField
3971
- // (it's rendered inline above with text/value separation).
3972
- const agentConfigFieldsNoSelector = configBuilder.buildFields(displayConfig);
3973
3876
  const tabs = [
3974
3877
  {
3975
3878
  id: 'agent',
3976
3879
  label: `🤖 ${agentDisplayName(defaultAgent)}`,
3977
- fields: agentConfigFieldsNoSelector,
3880
+ fields: [agentSelectorField, ...configBuilder.buildFields(displayConfig)],
3978
3881
  },
3979
3882
  {
3980
3883
  id: 'idle',
@@ -3994,28 +3897,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3994
3897
  ],
3995
3898
  },
3996
3899
  ];
3997
- // 使用共享渲染模块构建卡片
3998
- const card = buildConfigCardFromTabs(tabs, displayConfig);
3999
- // Inject the agent selector as the first element in the agent tab section,
4000
- // right after the "**🤖 ..." header, so text/value can differ for uninstalled agents.
4001
- // NOTE: findIndex relies on the agent tab header starting with "**🤖" — if the
4002
- // tab label format changes, this fallback will fire and the selector moves to top.
4003
- const body = card.body;
4004
- if (body && Array.isArray(body.elements)) {
4005
- const headerIdx = body.elements.findIndex((el) => typeof el === 'object' &&
4006
- el !== null &&
4007
- el.tag === 'div' &&
4008
- el.text?.tag === 'lark_md' &&
4009
- (el.text?.content ?? '').startsWith('**🤖'));
4010
- if (headerIdx >= 0) {
4011
- body.elements.splice(headerIdx + 1, 0, agentSelector);
4012
- }
4013
- else {
4014
- getLogger().warn('[buildConfigCard] agent tab header not found, falling back to unshift');
4015
- body.elements.unshift(agentSelector);
4016
- }
4017
- }
4018
- return { card };
3900
+ // 使用共享渲染模块构建卡片(Agent 选择器作为普通 select 字段参与渲染)
3901
+ return { card: buildConfigCardFromTabs(tabs, displayConfig) };
4019
3902
  }
4020
3903
  /** /order 实现(public:测试直接调用,替代 as unknown as)。 */
4021
3904
  cmdOrder(args, _ctx, offset = 0) {
@@ -4046,49 +3929,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
4046
3929
  // 右侧「删除指令」按钮。两列均 weighted 1:1,跨行对齐。
4047
3930
  const leftColumn = order.alias
4048
3931
  ? [
4049
- {
4050
- tag: 'button',
4051
- text: { tag: 'plain_text', content: `$${order.alias}` },
4052
- type: 'default',
4053
- size: 'small',
4054
- behaviors: [
4055
- {
4056
- type: 'callback',
4057
- value: { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset },
4058
- },
4059
- ],
4060
- },
4061
- {
4062
- tag: 'button',
4063
- text: { tag: 'plain_text', content: '✕' },
4064
- type: 'default',
4065
- size: 'small',
4066
- behaviors: [
4067
- {
4068
- type: 'callback',
4069
- value: {
4070
- cmd: 'order.aliasRemove',
4071
- orderId: order.id,
4072
- aliasName: order.alias,
4073
- offset: safeOffset,
4074
- },
4075
- },
4076
- ],
4077
- },
3932
+ cardButton(`$${order.alias}`, { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
3933
+ cardButton('✕', {
3934
+ cmd: 'order.aliasRemove',
3935
+ orderId: order.id,
3936
+ aliasName: order.alias,
3937
+ offset: safeOffset,
3938
+ }, { type: 'default', size: 'small' }),
4078
3939
  ]
4079
3940
  : [
4080
- {
4081
- tag: 'button',
4082
- text: { tag: 'plain_text', content: '+别名' },
4083
- type: 'default',
4084
- size: 'small',
4085
- behaviors: [
4086
- {
4087
- type: 'callback',
4088
- value: { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset },
4089
- },
4090
- ],
4091
- },
3941
+ cardButton('+别名', { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
4092
3942
  ];
4093
3943
  elements.push({
4094
3944
  tag: 'column_set',
@@ -4112,18 +3962,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
4112
3962
  tag: 'column',
4113
3963
  width: 'auto',
4114
3964
  elements: [
4115
- {
4116
- tag: 'button',
4117
- text: { tag: 'plain_text', content: '▶ 执行' },
4118
- type: 'primary',
4119
- size: 'small',
4120
- behaviors: [
4121
- {
4122
- type: 'callback',
4123
- value: { cmd: 'order.exec', orderId: order.id },
4124
- },
4125
- ],
4126
- },
3965
+ cardButton('▶ 执行', { cmd: 'order.exec', orderId: order.id }, { type: 'primary', size: 'small' }),
4127
3966
  ],
4128
3967
  },
4129
3968
  {
@@ -4133,44 +3972,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
4133
3972
  tag: 'column',
4134
3973
  width: 'auto',
4135
3974
  elements: [
4136
- {
4137
- tag: 'button',
4138
- text: { tag: 'plain_text', content: '编辑' },
4139
- type: 'default',
4140
- size: 'small',
4141
- behaviors: [
4142
- {
4143
- type: 'callback',
4144
- value: {
4145
- cmd: 'order.textEdit',
4146
- orderId: order.id,
4147
- offset: safeOffset,
4148
- },
4149
- },
4150
- ],
4151
- },
3975
+ cardButton('编辑', { cmd: 'order.textEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
4152
3976
  ],
4153
3977
  },
4154
3978
  {
4155
3979
  tag: 'column',
4156
3980
  width: 'auto',
4157
3981
  elements: [
4158
- {
4159
- tag: 'button',
4160
- text: { tag: 'plain_text', content: '删除' },
4161
- type: 'danger',
4162
- size: 'small',
4163
- behaviors: [
4164
- {
4165
- type: 'callback',
4166
- value: {
4167
- cmd: 'order.delete',
4168
- orderId: order.id,
4169
- offset: safeOffset,
4170
- },
4171
- },
4172
- ],
4173
- },
3982
+ cardButton('删除', { cmd: 'order.delete', orderId: order.id, offset: safeOffset }, { type: 'danger', size: 'small' }),
4174
3983
  ],
4175
3984
  },
4176
3985
  ],