lark-remote 0.5.19 → 0.5.20

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 (119) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +1 -1
  10. package/dist/bridge/index.d.ts +97 -153
  11. package/dist/bridge/index.js +182 -809
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +109 -97
  18. package/dist/card/card-budget.js +1 -1
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +34 -93
  24. package/dist/card/run-state.d.ts +1 -1
  25. package/dist/card/run-state.js +28 -90
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +1 -1
  28. package/dist/card/usage-format.d.ts +12 -0
  29. package/dist/card/usage-format.js +8 -3
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/config/codex-config.js +130 -106
  33. package/dist/config/index.d.ts +16 -2
  34. package/dist/config/index.js +23 -8
  35. package/dist/connector/index.js +6 -6
  36. package/dist/error-classification.js +3 -2
  37. package/dist/inbound/placeholder.d.ts +1 -1
  38. package/dist/inbound/placeholder.js +1 -1
  39. package/dist/index.js +203 -133
  40. package/dist/logger/index.js +6 -6
  41. package/dist/message-handlers.d.ts +0 -5
  42. package/dist/message-handlers.js +1 -1
  43. package/dist/model-catalog.d.ts +2 -2
  44. package/dist/model-catalog.js +3 -8
  45. package/dist/platform/identity.js +1 -1
  46. package/dist/platform/path.d.ts +1 -1
  47. package/dist/platform/path.js +1 -1
  48. package/dist/platform/sleep-blocker.js +1 -1
  49. package/dist/platform/terminator-win32.js +2 -1
  50. package/dist/router/commands/resume.js +2 -20
  51. package/dist/router/config/dsh.d.ts +4 -8
  52. package/dist/router/config/dsh.js +3 -10
  53. package/dist/router/config/kimi.js +1 -1
  54. package/dist/router/config/types.d.ts +1 -1
  55. package/dist/router/config-diff.js +1 -1
  56. package/dist/router/index.d.ts +56 -11
  57. package/dist/router/index.js +219 -200
  58. package/dist/runner/claude/session.d.ts +1 -1
  59. package/dist/runner/claude/session.js +6 -4
  60. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  61. package/dist/runner/codex/app-server/runner.js +30 -25
  62. package/dist/runner/codex/app-server/translator.d.ts +0 -5
  63. package/dist/runner/codex/app-server/translator.js +4 -17
  64. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  65. package/dist/runner/common/acp/acp-approval.js +1 -1
  66. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  67. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  68. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  69. package/dist/runner/common/acp/base-translator.js +31 -9
  70. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  71. package/dist/runner/common/acp/protocol-types.js +2 -2
  72. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  73. package/dist/runner/common/connection-based-runner.js +32 -2
  74. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  75. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  76. package/dist/runner/common/spawning-runner.d.ts +1 -1
  77. package/dist/runner/common/spawning-runner.js +1 -1
  78. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  79. package/dist/runner/dsh/catalog-probe.js +10 -0
  80. package/dist/runner/dsh/client.d.ts +1 -3
  81. package/dist/runner/dsh/client.js +2 -4
  82. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  83. package/dist/runner/dsh/exit-cancel.js +2 -2
  84. package/dist/runner/dsh/inflight.d.ts +1 -1
  85. package/dist/runner/dsh/inflight.js +1 -1
  86. package/dist/runner/dsh/runner.d.ts +40 -1
  87. package/dist/runner/dsh/runner.js +272 -199
  88. package/dist/runner/dsh/translator.d.ts +1 -1
  89. package/dist/runner/dsh/translator.js +1 -1
  90. package/dist/runner/index.d.ts +1 -1
  91. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  92. package/dist/runner/kimi/acp/runner.js +1 -2
  93. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  94. package/dist/runner/kimi/acp/translator.js +3 -4
  95. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  96. package/dist/runner/opencode/acp/runner.js +4 -4
  97. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  98. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  99. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  100. package/dist/runner/pi/rpc/runner.js +27 -42
  101. package/dist/runner/types.d.ts +2 -2
  102. package/dist/runner/types.js +1 -1
  103. package/dist/session/claude/sessions.d.ts +45 -0
  104. package/dist/session/claude/sessions.js +97 -71
  105. package/dist/session/codex/rollout-reader.js +4 -4
  106. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  107. package/dist/session/common/throw-session-list-error.js +16 -0
  108. package/dist/session/dsh/sessions.js +11 -5
  109. package/dist/session/kimi/sessions.d.ts +40 -1
  110. package/dist/session/kimi/sessions.js +244 -192
  111. package/dist/session/opencode/sessions.d.ts +153 -1
  112. package/dist/session/opencode/sessions.js +354 -321
  113. package/dist/session/pi/sessions.js +4 -5
  114. package/dist/session/session-store.d.ts +1 -1
  115. package/dist/session/session-store.js +1 -1
  116. package/dist/update/version-check.js +2 -1
  117. package/dist/workspace/index.d.ts +1 -1
  118. package/dist/workspace/index.js +1 -1
  119. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ import type { Runner } from '../runner/index.js';
2
+ import type { AgentKind } from '../agent-kinds.js';
3
+ import type { CommandContext } from './index.js';
4
+ import type { RunCardSnapshot } from './index.js';
5
+ /**
6
+ * 终态卡后台任务订阅与停止(bridge 结构治理第 2 刀)。
7
+ *
8
+ * run 的卡片在 `result` 那一帧就关闭了,但后台任务(DSH/kimi 的宿主后台作业)
9
+ * 的生命周期跨 run:job.kill 停止、名单订阅、PATCH 串行链全部收在这里,与
10
+ * 卡片快照(bridge 侧 runCardSnapshots)按 runId 对齐。
11
+ *
12
+ * 依赖显式注入(与 ApprovalActions / CompactLifecycle 同一模式)。两张核心
13
+ * 状态表都归本类所有:
14
+ * - `jobWatches`:runId → 中止句柄。与卡片快照同生命周期——快照被淘汰、任务
15
+ * 全结束、patch 失败时退订。上限天然受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡
16
+ * 最多一条流)。
17
+ * - `jobCardPatches`:同一张卡的 patch 串行链(runId → 链尾)。「停止按钮」和
18
+ * 「订阅帧」都会改同一张终态卡:不串行化时两条 PATCH 并发,后发先至的那条
19
+ * 会把卡片写回旧内容(任务又变回「运行中」)。
20
+ */
21
+ export interface JobWatchLifecycleDeps {
22
+ getRunner(cwd: string, kind?: AgentKind): Runner;
23
+ /** 终态快照表在 bridge 侧(rememberRunCardSnapshot 收口),这里只读。 */
24
+ getRunCardSnapshot(runId: string): RunCardSnapshot | undefined;
25
+ /** 「停止任务」后的整卡重渲染(失败回退发新卡)。 */
26
+ updateCardInPlace(card: object, ctx: CommandContext): Promise<boolean>;
27
+ /** 订阅帧的原位 PATCH(已知卡,失败不补发)。 */
28
+ patchCard(messageId: string, card: object): Promise<boolean>;
29
+ }
30
+ export declare class JobWatchLifecycle {
31
+ private readonly deps;
32
+ /** 终态卡后台任务订阅(runId → 中止句柄)。 */
33
+ private jobWatches;
34
+ /** 同一张卡的 patch 串行链(runId → 链尾)。 */
35
+ private jobCardPatches;
36
+ constructor(deps: JobWatchLifecycleDeps);
37
+ /** 退订一张终态卡的后台任务名单(幂等)。 */
38
+ stopJobWatch(runId: string): void;
39
+ /**
40
+ * 停止终态卡上的一个后台任务。
41
+ *
42
+ * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
43
+ * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
44
+ * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
45
+ * 会留在卡片上而任务早已结束。
46
+ */
47
+ killBackgroundJob(runId: string, jobId: string, ctx: CommandContext): Promise<boolean>;
48
+ /**
49
+ * 让终态卡的后台任务小节跟着宿主继续更新。
50
+ *
51
+ * run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
52
+ * 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
53
+ * 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
54
+ * 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
55
+ */
56
+ startBackgroundJobWatch(runId: string, snapshot: RunCardSnapshot, runner: Runner): void;
57
+ /** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
58
+ private consumeJobRoster;
59
+ /** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
60
+ private markJobsUnknown;
61
+ /**
62
+ * 一张终态卡的 PATCH 串行链。
63
+ *
64
+ * 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
65
+ * 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
66
+ */
67
+ private enqueueJobCardPatch;
68
+ }
@@ -0,0 +1,146 @@
1
+ import { isLiveBackgroundJob, markBackgroundJobsUnknown, removeBackgroundJob, syncBackgroundJobs, } from '../card/run-state.js';
2
+ import { renderRunCard } from '../card/run-renderer.js';
3
+ import { getLogger } from '../logger/index.js';
4
+ import { errorMessage } from './final-usage.js';
5
+ export class JobWatchLifecycle {
6
+ deps;
7
+ /** 终态卡后台任务订阅(runId → 中止句柄)。 */
8
+ jobWatches = new Map();
9
+ /** 同一张卡的 patch 串行链(runId → 链尾)。 */
10
+ jobCardPatches = new Map();
11
+ constructor(deps) {
12
+ this.deps = deps;
13
+ }
14
+ /** 退订一张终态卡的后台任务名单(幂等)。 */
15
+ stopJobWatch(runId) {
16
+ this.jobWatches.get(runId)?.abort();
17
+ this.jobWatches.delete(runId);
18
+ this.jobCardPatches.delete(runId);
19
+ }
20
+ /**
21
+ * 停止终态卡上的一个后台任务。
22
+ *
23
+ * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
24
+ * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
25
+ * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
26
+ * 会留在卡片上而任务早已结束。
27
+ */
28
+ async killBackgroundJob(runId, jobId, ctx) {
29
+ const snapshot = this.deps.getRunCardSnapshot(runId);
30
+ const sessionId = snapshot?.state.sessionId;
31
+ if (!snapshot || !sessionId)
32
+ return false;
33
+ const runner = this.deps.getRunner(snapshot.cwd, snapshot.agentKind);
34
+ if (typeof runner.killJob !== 'function')
35
+ return false;
36
+ const killed = await runner.killJob(sessionId, jobId);
37
+ if (!killed)
38
+ return false;
39
+ snapshot.state = removeBackgroundJob(snapshot.state, jobId);
40
+ // 与订阅帧共用一条串行链:两条 PATCH 并发时,后发先至的那条会把卡片写回
41
+ // 旧内容(刚停掉的任务又显示成「运行中」)。
42
+ await this.enqueueJobCardPatch(runId, async () => {
43
+ await this.deps.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
44
+ return true;
45
+ });
46
+ // 卡片上已经没有在跑的任务:不必再等宿主推下一帧,直接退订。
47
+ if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
48
+ this.stopJobWatch(runId);
49
+ }
50
+ return true;
51
+ }
52
+ /**
53
+ * 让终态卡的后台任务小节跟着宿主继续更新。
54
+ *
55
+ * run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
56
+ * 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
57
+ * 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
58
+ * 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
59
+ */
60
+ startBackgroundJobWatch(runId, snapshot, runner) {
61
+ const live = (snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob);
62
+ if (!live)
63
+ return;
64
+ const sessionId = snapshot.state.sessionId;
65
+ const messageId = snapshot.messageId;
66
+ const watchJobs = runner.watchJobs;
67
+ if (!sessionId || !messageId || typeof watchJobs !== 'function') {
68
+ // 没有卡 id 就没有 patch 的落点:保留收尾快照,不另发一张卡。
69
+ getLogger().info(`[lark-remote] background jobs stay on the turn snapshot runId=${runId} ` +
70
+ `(sessionId=${sessionId ?? '-'} messageId=${messageId ?? '-'} watchable=${typeof watchJobs === 'function'})`);
71
+ return;
72
+ }
73
+ const controller = new AbortController();
74
+ this.jobWatches.set(runId, controller);
75
+ void this.consumeJobRoster(runId, snapshot, controller, (signal) => watchJobs.call(runner, sessionId, signal));
76
+ }
77
+ /** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
78
+ async consumeJobRoster(runId, snapshot, controller, watch) {
79
+ const messageId = snapshot.messageId;
80
+ if (!messageId)
81
+ return;
82
+ let allEnded = false;
83
+ // 订阅帧 PATCH 已失败 → 卡片不可达:finally 不再补 markJobsUnknown。
84
+ let cardLost = false;
85
+ try {
86
+ for await (const roster of watch(controller.signal)) {
87
+ const next = syncBackgroundJobs(snapshot.state, roster);
88
+ // 名单没变化就不打 PATCH:宿主可能重复推同一份。
89
+ if (next === snapshot.state)
90
+ continue;
91
+ snapshot.state = next;
92
+ const ok = await this.enqueueJobCardPatch(runId, () => this.deps.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
93
+ if (!ok) {
94
+ getLogger().warn(`[lark-remote] background job card update failed, stop watching runId=${runId}`);
95
+ // 卡片已不可达:markJobsUnknown 的补写注定再失败,且「标未知」
96
+ // 是写给卡片读者看的——没有卡就没有读者。置标志让 finally 跳过。
97
+ cardLost = true;
98
+ return;
99
+ }
100
+ if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
101
+ allEnded = true;
102
+ return;
103
+ }
104
+ }
105
+ }
106
+ catch (err) {
107
+ if (!controller.signal.aborted) {
108
+ getLogger().warn(`[lark-remote] background job watch failed runId=${runId}: ${errorMessage(err)}`);
109
+ }
110
+ }
111
+ finally {
112
+ const externallyAborted = controller.signal.aborted;
113
+ controller.abort();
114
+ // 流断了而任务没结算:监控中断 ≠ 任务结束,标成未知而不是继续写「运行中」。
115
+ // 但卡片 PATCH 已失败(cardLost)时不补写:补写注定失败且语义错位。
116
+ if (!allEnded && !cardLost && !externallyAborted)
117
+ await this.markJobsUnknown(runId, snapshot);
118
+ this.jobWatches.delete(runId);
119
+ this.jobCardPatches.delete(runId);
120
+ }
121
+ }
122
+ /** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
123
+ async markJobsUnknown(runId, snapshot) {
124
+ const next = markBackgroundJobsUnknown(snapshot.state);
125
+ if (next === snapshot.state)
126
+ return;
127
+ snapshot.state = next;
128
+ const messageId = snapshot.messageId;
129
+ if (!messageId)
130
+ return;
131
+ await this.enqueueJobCardPatch(runId, () => this.deps.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
132
+ }
133
+ /**
134
+ * 一张终态卡的 PATCH 串行链。
135
+ *
136
+ * 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
137
+ * 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
138
+ */
139
+ enqueueJobCardPatch(runId, task) {
140
+ const previous = this.jobCardPatches.get(runId) ?? Promise.resolve();
141
+ const next = previous.then(task, task);
142
+ this.jobCardPatches.set(runId, next.then(() => undefined, () => undefined));
143
+ return next;
144
+ }
145
+ }
146
+ //# sourceMappingURL=job-watch.js.map
@@ -11,7 +11,7 @@ export interface AgentBinding {
11
11
  * 会话代际快照(与 sessionId 同刻捕获)。随 binding 带到执行时刻:若执行前
12
12
  * session 指针被移动(/new、/cd、/resume、/config 切换),该 run 的 init /
13
13
  * turn_started 写回判 stale 跳过,避免「先发消息 → 后点新会话」时旧 sessionId
14
- * 被写回复活(2026-10-07 事故)。缺省时回退到执行起点捕获,保持旧行为。
14
+ * 被写回复活。缺省时回退到执行起点捕获,保持旧行为。
15
15
  */
16
16
  sessionEpoch?: number;
17
17
  }
@@ -161,7 +161,7 @@ export declare class QueueManager {
161
161
  * interrupt credit to exactly the interrupted task's slot) and the display
162
162
  * info the queue card needs to name what a waiting message waits for.
163
163
  *
164
- * 2026-10-09 线上:一次 Compact 占着 lane,新消息的排队卡却写「位置: 第 1
164
+ * 一次 Compact 占着 lane,新消息的排队卡曾写「位置: 第 1
165
165
  * 位 / 前面还有: 0 条消息」——开卡判据看 lane(含正在执行者),位置文案只看
166
166
  * 等待队列,两个口径的差集正是这条记录。
167
167
  */
@@ -276,7 +276,7 @@ export declare class QueueManager {
276
276
  /**
277
277
  * 排队卡头部。只有前面确实还有等待中的消息时才说「排队」;前面为空时
278
278
  * 用户等的是当前正在执行的任务(例如 Compact),用「等待当前任务结束」
279
- * 表达才不会与「前面还有 0 条」自相矛盾(2026-10-09 线上实例)。
279
+ * 表达才不会与「前面还有 0 条」自相矛盾。
280
280
  */
281
281
  private queueCardHeader;
282
282
  /** Send a queue status card showing current queue position and actions. */
@@ -1,5 +1,5 @@
1
1
  import { getLogger } from '../logger/index.js';
2
- import { cardWithHeader } from '../card/card-shared.js';
2
+ import { cardButton, cardWithHeader } from '../card/card-shared.js';
3
3
  import { displayName } from '../platform/path.js';
4
4
  /**
5
5
  * Per-workspace cap on the number of tasks WAITING to execute.
@@ -71,7 +71,7 @@ export class QueueManager {
71
71
  * interrupt credit to exactly the interrupted task's slot) and the display
72
72
  * info the queue card needs to name what a waiting message waits for.
73
73
  *
74
- * 2026-10-09 线上:一次 Compact 占着 lane,新消息的排队卡却写「位置: 第 1
74
+ * 一次 Compact 占着 lane,新消息的排队卡曾写「位置: 第 1
75
75
  * 位 / 前面还有: 0 条消息」——开卡判据看 lane(含正在执行者),位置文案只看
76
76
  * 等待队列,两个口径的差集正是这条记录。
77
77
  */
@@ -227,8 +227,8 @@ export class QueueManager {
227
227
  }
228
228
  // 身份不变量 tripwire:同一 cwd 内 queue key 必须唯一(一次入队动作一个
229
229
  // key,铸造点 router.mintQueueTaskKey)。撞 key 时数组(追加)与 taskIndex
230
- // (覆盖)必然漂移,位置显示/撤销/立即执行/卡片更新会串到别的条目上——
231
- // 2026-10-08 线上缺陷就是这么长出来的。这里不改行为,只留证据。
230
+ // (覆盖)必然漂移,位置显示/撤销/立即执行/卡片更新会串到别的条目上。
231
+ // 这里不改行为,只留证据。
232
232
  if (this.indexGet(cwd, queuedTask.messageId)) {
233
233
  getLogger().warn(`[queue-manager] duplicate queue key cwd=${cwd} messageId=${queuedTask.messageId} — ` +
234
234
  'queue identity must be minted per enqueue action (see router.mintQueueTaskKey)');
@@ -304,8 +304,7 @@ export class QueueManager {
304
304
  // so pruning the oldest entries is safe.
305
305
  if (this.beganMessageIds.size >= 10_000) {
306
306
  const oldest = this.beganMessageIds.values().next().value;
307
- if (oldest !== undefined)
308
- this.beganMessageIds.delete(oldest);
307
+ this.beganMessageIds.delete(oldest);
309
308
  }
310
309
  this.beganMessageIds.add(messageId);
311
310
  }
@@ -453,7 +452,7 @@ export class QueueManager {
453
452
  /**
454
453
  * 排队卡头部。只有前面确实还有等待中的消息时才说「排队」;前面为空时
455
454
  * 用户等的是当前正在执行的任务(例如 Compact),用「等待当前任务结束」
456
- * 表达才不会与「前面还有 0 条」自相矛盾(2026-10-09 线上实例)。
455
+ * 表达才不会与「前面还有 0 条」自相矛盾。
457
456
  */
458
457
  queueCardHeader(tasksAhead) {
459
458
  return {
@@ -537,7 +536,7 @@ export class QueueManager {
537
536
  if (occupant) {
538
537
  // 只有可能挡住队列、需要用户归因的占用者才自报身份。「正在执行: 💬 消息」
539
538
  // 对普通消息占用者没有信息量——用户刚发的就是消息,「💡 消息将按顺序执行…」
540
- // 也只是复述排队本身,一并省去(2026-10-11)。压缩额外说明何时轮到用户。
539
+ // 也只是复述排队本身,一并省去。压缩额外说明何时轮到用户。
541
540
  if (occupant.kind !== 'message') {
542
541
  elements.push({
543
542
  tag: 'div',
@@ -572,13 +571,7 @@ export class QueueManager {
572
571
  // Edit button: a pending (queued) task is editable by default. The
573
572
  // executing/cancelled states render via dedicated card builders that
574
573
  // hard-disable all buttons, so this builder only serves pending cards.
575
- elements.push({
576
- tag: 'button',
577
- text: { tag: 'plain_text', content: '✏️ 编辑' },
578
- type: 'default',
579
- size: 'small',
580
- behaviors: [{ type: 'callback', value: { cmd: 'queue.edit', cwd, messageId } }],
581
- });
574
+ elements.push(cardButton('✏️ 编辑', { cmd: 'queue.edit', cwd, messageId }, { type: 'default', size: 'small' }));
582
575
  }
583
576
  elements.push({ tag: 'hr' });
584
577
  }
@@ -604,20 +597,8 @@ export class QueueManager {
604
597
  */
605
598
  buildQueueActionButtons(cwd, messageId, disabled, opts) {
606
599
  return [
607
- {
608
- tag: 'button',
609
- text: { tag: 'plain_text', content: '❌ 撤销' },
610
- type: 'danger',
611
- disabled,
612
- behaviors: [{ type: 'callback', value: { cmd: 'queue.cancel', cwd, messageId } }],
613
- },
614
- {
615
- tag: 'button',
616
- text: { tag: 'plain_text', content: opts?.immediateLabel ?? '⚡ 立即执行' },
617
- type: 'primary',
618
- disabled: disabled || opts?.immediateLabel !== undefined,
619
- behaviors: [{ type: 'callback', value: { cmd: 'queue.immediate', cwd, messageId } }],
620
- },
600
+ cardButton('❌ 撤销', { cmd: 'queue.cancel', cwd, messageId }, { type: 'danger', disabled }),
601
+ cardButton(opts?.immediateLabel ?? '⚡ 立即执行', { cmd: 'queue.immediate', cwd, messageId }, { type: 'primary', disabled: disabled || opts?.immediateLabel !== undefined }),
621
602
  ];
622
603
  }
623
604
  /**
@@ -10,7 +10,7 @@ import { type RunTerminal } from './run-state.js';
10
10
  * Render the approval area for a run card.
11
11
  * Returns an array of CardKit 2.0 elements to append to the card body.
12
12
  */
13
- export declare function renderApprovalArea(approval: ApprovalView | undefined, opts?: {
13
+ export declare function renderApprovalArea(approval: ApprovalView, opts?: {
14
14
  expired?: boolean;
15
15
  runId?: string;
16
16
  terminal?: RunTerminal;
@@ -24,7 +24,7 @@ export declare function renderApprovalArea(approval: ApprovalView | undefined, o
24
24
  * 层一起超限,超限卡被飞书整卡拒收(11310)→ 审批不可见。兜底层换用本入口后
25
25
  * 结构性保证 ≤ 几个元素 + ≤ 1KB 文本,审批入口(红线)在任何降级档都活着。
26
26
  */
27
- export declare function renderApprovalAreaCompact(approval: ApprovalView | undefined, opts?: {
27
+ export declare function renderApprovalAreaCompact(approval: ApprovalView, opts?: {
28
28
  expired?: boolean;
29
29
  runId?: string;
30
30
  terminal?: RunTerminal;
@@ -71,8 +71,6 @@ function pushIf(arr, el) {
71
71
  * Returns an array of CardKit 2.0 elements to append to the card body.
72
72
  */
73
73
  export function renderApprovalArea(approval, opts) {
74
- if (!approval)
75
- return [];
76
74
  // 终态(done/error/interrupted/idle_timeout)下 run 已结束:coordinator 已
77
75
  // 释放,按钮点击无处响应,隐藏整个审批区避免"点了没反应"。
78
76
  if (isTerminalHidden(opts))
@@ -123,8 +121,6 @@ export function renderApprovalArea(approval, opts) {
123
121
  * 结构性保证 ≤ 几个元素 + ≤ 1KB 文本,审批入口(红线)在任何降级档都活着。
124
122
  */
125
123
  export function renderApprovalAreaCompact(approval, opts) {
126
- if (!approval)
127
- return [];
128
124
  if (isTerminalHidden(opts))
129
125
  return [];
130
126
  const elements = [
@@ -533,112 +529,128 @@ function renderQuestionApproval(approval, expired, runId) {
533
529
  break;
534
530
  }
535
531
  renderedQuestions++;
536
- const q = questions[i];
537
- const selected = q.selected ?? [];
538
- const header = capApprovalText(q.header || `问题 ${i + 1}`, APPROVAL_LABEL_BYTES);
539
- const freeText = (q.options?.length ?? 0) === 0;
532
+ elements.push(...renderQuestionBlock(approval, i, runId));
533
+ }
534
+ // 提问卡底部统一「跳过回答」:走现有 approval.respond + decision=decline
535
+ // (coordinator 对 question 允许 decline 作为安全兜底),runner 按协议映射
536
+ // 跳过语义(Claude deny / Codex 空 answers / Kimi action decline / Pi cancelled)。
537
+ if (!expired) {
538
+ elements.push(cardButton('⏭️ 跳过回答', {
539
+ cmd: 'approval.respond',
540
+ decision: 'decline',
541
+ requestId: approval.requestId,
542
+ runId,
543
+ nonce: mkNonce(),
544
+ }, { type: 'default' }));
545
+ }
546
+ return elements;
547
+ }
548
+ /**
549
+ * 渲染单题区块:题面 + 详情面板 + 选项行/输入框 + (多选)提交按钮。
550
+ * 从 renderQuestionApproval 的循环体抽出;循环保留题数/元素预算裁剪与
551
+ * 卡片装配。交互组件(按钮/输入框)继续直挂 body elements——本函数输出
552
+ * 必须始终被 expectNoV1ActionContainer 断言覆盖(200861 红线,见
553
+ * run-renderer.test.ts 的 question 系列);nonce 仍由各交互元素生成时
554
+ * 各自调用一次 mkNonce(),不在块外缓存复用。
555
+ */
556
+ function renderQuestionBlock(approval, qIndex, runId) {
557
+ const q = approval.questions?.[qIndex];
558
+ // 越界防御(正常调用由循环边界保证);不虚构题面。
559
+ if (!q)
560
+ return [];
561
+ const elements = [];
562
+ const selected = q.selected ?? [];
563
+ const header = capApprovalText(q.header || `问题 ${qIndex + 1}`, APPROVAL_LABEL_BYTES);
564
+ const freeText = (q.options?.length ?? 0) === 0;
565
+ elements.push({
566
+ tag: 'div',
567
+ text: {
568
+ tag: 'lark_md',
569
+ content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${capApprovalText(q.question)}`,
570
+ },
571
+ });
572
+ if (q.detail) {
573
+ // 题面外的补充正文(DSH plan-review 提问把计划全文放这里):必须随题
574
+ // 渲染,否则用户是在「看不见计划」的情况下批准。与 ExitPlanMode 的
575
+ // 计划面板同口径:反引号中和 + 表格限数 + 字节预算折叠展示。
576
+ const neutralized = q.detail.replace(/`/g, '·');
577
+ const content = truncateUtf8(truncateMarkdownTables(neutralized), PLAN_PANEL_BYTES);
578
+ elements.push(collapsibleMarkdownPanel({
579
+ title: '📋 详情',
580
+ expanded: false,
581
+ border: 'blue',
582
+ content,
583
+ }));
584
+ }
585
+ if (freeText) {
586
+ // 自由文本题(Codex options:null / Pi extension input):无选项行,
587
+ // 直接渲染题面 + 输入框,提交走 answerCustom(coordinator 不要求有选项)。
588
+ elements.push(renderAnswerInput(approval.requestId, qIndex, q.placeholder ?? '输入回答…', runId));
589
+ // 已答回显:input_value 不跨卡片更新保留,多题场景下用户提交后若还有
590
+ // 其他问题未答,卡片必须展示已答文本(与自定义答案选中态同模式)。
591
+ const answered = q.selected?.find((s) => s.length > 0);
592
+ if (answered) {
593
+ elements.push({
594
+ tag: 'div',
595
+ text: { tag: 'lark_md', content: `✍️ 已答:${capApprovalText(answered)}` },
596
+ });
597
+ }
598
+ return elements;
599
+ }
600
+ // 选项渲染上限(label 是回调 payload 不可截断,只能按条数折叠)。
601
+ const options = q.options ?? [];
602
+ for (const option of options.slice(0, MAX_RENDERED_OPTIONS)) {
603
+ const isSelected = selected.includes(option.label);
604
+ // 单选/多选图标区分:单选 ⚪/🔵(radio 隐喻),多选 ⬜/☑️(checkbox 隐喻)。
605
+ const icon = q.multiSelect ? (isSelected ? '☑️' : '⬜') : isSelected ? '🔵' : '⚪';
606
+ elements.push(renderOptionRow(approval.requestId, qIndex, option, isSelected, icon, runId));
607
+ }
608
+ if (options.length > MAX_RENDERED_OPTIONS) {
540
609
  elements.push({
541
610
  tag: 'div',
542
611
  text: {
543
612
  tag: 'lark_md',
544
- content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${capApprovalText(q.question)}`,
613
+ content: `_…另有 ${options.length - MAX_RENDERED_OPTIONS} 个选项未展示(卡片元素预算)_`,
545
614
  },
546
615
  });
547
- if (q.detail) {
548
- // 题面外的补充正文(DSH plan-review 提问把计划全文放这里):必须随题
549
- // 渲染,否则用户是在「看不见计划」的情况下批准。与 ExitPlanMode 的
550
- // 计划面板同口径:反引号中和 + 表格限数 + 字节预算折叠展示。
551
- const neutralized = q.detail.replace(/`/g, '·');
552
- const content = truncateUtf8(truncateMarkdownTables(neutralized), PLAN_PANEL_BYTES);
553
- elements.push(collapsibleMarkdownPanel({
554
- title: '📋 详情',
555
- expanded: false,
556
- border: 'blue',
557
- content,
558
- }));
559
- }
560
- if (freeText) {
561
- // 自由文本题(Codex options:null / Pi extension input):无选项行,
562
- // 直接渲染题面 + 输入框,提交走 answerCustom(coordinator 不要求有选项)。
563
- elements.push(renderAnswerInput(approval.requestId, i, q.placeholder ?? '输入回答…', runId));
564
- // 已答回显:input_value 不跨卡片更新保留,多题场景下用户提交后若还有
565
- // 其他问题未答,卡片必须展示已答文本(与自定义答案选中态同模式)。
566
- const answered = q.selected?.find((s) => s.length > 0);
567
- if (answered) {
568
- elements.push({
569
- tag: 'div',
570
- text: { tag: 'lark_md', content: `✍️ 已答:${capApprovalText(answered)}` },
571
- });
572
- }
573
- continue;
574
- }
575
- // 选项渲染上限(label 是回调 payload 不可截断,只能按条数折叠)。
576
- const options = q.options ?? [];
577
- for (const option of options.slice(0, MAX_RENDERED_OPTIONS)) {
578
- const isSelected = selected.includes(option.label);
579
- // 单选/多选图标区分:单选 ⚪/🔵(radio 隐喻),多选 ⬜/☑️(checkbox 隐喻)。
580
- const icon = q.multiSelect ? (isSelected ? '☑️' : '⬜') : isSelected ? '🔵' : '⚪';
581
- elements.push(renderOptionRow(approval.requestId, i, option, isSelected, icon, runId));
582
- }
583
- if (options.length > MAX_RENDERED_OPTIONS) {
584
- elements.push({
585
- tag: 'div',
586
- text: {
587
- tag: 'lark_md',
588
- content: `_…另有 ${options.length - MAX_RENDERED_OPTIONS} 个选项未展示(卡片元素预算)_`,
589
- },
590
- });
591
- }
592
- // 自定义答案(Other)的选中态展示:选项按钮无法表示自由文本,单独显示
593
- // 已选文本,避免「点了没反应」的困惑。
594
- const customSelected = q.selected?.find((s) => !q.options.some((o) => o.label === s));
595
- if (customSelected) {
616
+ }
617
+ // 自定义答案(Other)的选中态展示:选项按钮无法表示自由文本,单独显示
618
+ // 已选文本,避免「点了没反应」的困惑。
619
+ const customSelected = q.selected?.find((s) => !q.options.some((o) => o.label === s));
620
+ if (customSelected) {
621
+ elements.push({
622
+ tag: 'div',
623
+ text: { tag: 'lark_md', content: `✍️ 自定义答案:${capApprovalText(customSelected)}` },
624
+ });
625
+ }
626
+ // 单选问题提供自定义答案(Other)输入——输入文本后提交
627
+ // (移动端键盘回车/完成键,桌面端输入框右侧提交图标),input_value 经
628
+ // connector raw 事件回传。
629
+ // 显隐按 isOther !== false:Kimi form 会丢弃非声明选项值,翻译时置
630
+ // isOther=false 隐藏输入;Claude 未设置该字段默认显示(行为不变)。
631
+ if (!q.multiSelect && q.isOther !== false) {
632
+ elements.push(renderAnswerInput(approval.requestId, qIndex, '自定义答案(Other)…', runId, 'approval.answerCustom'));
633
+ }
634
+ // Codex user_note:选项之外的补充说明(allowNote 数据驱动门控)。
635
+ // 提交走 answerNote(coordinator 记录 note,随答案一起提交),已填回显。
636
+ if (q.allowNote === true) {
637
+ elements.push(renderAnswerInput(approval.requestId, qIndex, '补充说明(可选)…', runId, 'approval.answerNote'));
638
+ if (q.note) {
596
639
  elements.push({
597
640
  tag: 'div',
598
- text: { tag: 'lark_md', content: `✍️ 自定义答案:${capApprovalText(customSelected)}` },
641
+ text: { tag: 'lark_md', content: `📝 ${capApprovalText(q.note)}` },
599
642
  });
600
643
  }
601
- // 单选问题提供自定义答案(Other)输入——输入文本后提交
602
- // (移动端键盘回车/完成键,桌面端输入框右侧提交图标),input_value 经
603
- // connector raw 事件回传。
604
- // 显隐按 isOther !== false:Kimi form 会丢弃非声明选项值,翻译时置
605
- // isOther=false 隐藏输入;Claude 未设置该字段默认显示(行为不变)。
606
- if (!q.multiSelect && q.isOther !== false) {
607
- elements.push(renderAnswerInput(approval.requestId, i, '自定义答案(Other)…', runId, 'approval.answerCustom'));
608
- }
609
- // Codex user_note:选项之外的补充说明(allowNote 数据驱动门控)。
610
- // 提交走 answerNote(coordinator 记录 note,随答案一起提交),已填回显。
611
- if (q.allowNote === true) {
612
- elements.push(renderAnswerInput(approval.requestId, i, '补充说明(可选)…', runId, 'approval.answerNote'));
613
- if (q.note) {
614
- elements.push({
615
- tag: 'div',
616
- text: { tag: 'lark_md', content: `📝 ${capApprovalText(q.note)}` },
617
- });
618
- }
619
- }
620
- // 多选问题:有勾选才给提交按钮(单选在协调器内即时提交)。
621
- if (q.multiSelect && selected.length > 0) {
622
- elements.push(cardButton(`✅ 提交答案(已选 ${selected.length} 项)`, {
623
- cmd: 'approval.answerSubmit',
624
- requestId: approval.requestId,
625
- questionIndex: i,
626
- runId,
627
- nonce: mkNonce(),
628
- }, { type: 'primary' }));
629
- }
630
644
  }
631
- // 提问卡底部统一「跳过回答」:走现有 approval.respond + decision=decline
632
- // (coordinator 对 question 允许 decline 作为安全兜底),runner 按协议映射
633
- // 跳过语义(Claude deny / Codex 空 answers / Kimi action decline / Pi cancelled)。
634
- if (!expired) {
635
- elements.push(cardButton('⏭️ 跳过回答', {
636
- cmd: 'approval.respond',
637
- decision: 'decline',
645
+ // 多选问题:有勾选才给提交按钮(单选在协调器内即时提交)。
646
+ if (q.multiSelect && selected.length > 0) {
647
+ elements.push(cardButton(`✅ 提交答案(已选 ${selected.length} 项)`, {
648
+ cmd: 'approval.answerSubmit',
638
649
  requestId: approval.requestId,
650
+ questionIndex: qIndex,
639
651
  runId,
640
652
  nonce: mkNonce(),
641
- }, { type: 'default' }));
653
+ }, { type: 'primary' }));
642
654
  }
643
655
  return elements;
644
656
  }
@@ -693,7 +705,7 @@ function renderOptionRow(requestId, questionIndex, option, isSelected, icon, run
693
705
  function renderAnswerInput(requestId, questionIndex, placeholder, runId, cmd = 'approval.answerCustom') {
694
706
  // name 必须含 cmd,保证同一题同时渲染多个输入框(如单选选项题的
695
707
  // Other 输入 + Note 输入)时元素 name 唯一——飞书对同卡内重复 name
696
- // 报 ErrCode 11310 拒绝整卡(2026-08-19 线上:name(answer-custom-0-0) duplicate)。
708
+ // 报 ErrCode 11310 拒绝整卡。
697
709
  const kind = cmd === 'approval.answerNote' ? 'note' : 'custom';
698
710
  return {
699
711
  tag: 'input',
@@ -165,7 +165,7 @@ function enforceElementBudget(card, opts = {}, bytesBefore) {
165
165
  * when table truncation alone doesn't bring the card under the byte limit.
166
166
  *
167
167
  * 裁剪判定是**结构化**的:所有 collapsible_panel 都是待裁剪对象,不看面板
168
- * 标题文本。历史教训(2026-08-08):旧实现用
168
+ * 标题文本。历史教训:旧实现用
169
169
  * emoji 标题模式(🤖👤💭🔧🟢🔴)识别「会话事件面板」,但真实
170
170
  * sessionEventPanel 产出的标题是裸英文 type 名(emoji 在事件正文而非标题),
171
171
  * 导致裁剪阶段对真实 resume 卡零命中,任何 >28KB 的卡片无裁剪直通极端降级,
@@ -27,7 +27,7 @@ export declare function parseJumpOffset(inputValue: unknown, pageSize: number):
27
27
  * Build the CardKit 2.0 pagination block shared by /ls, /ws, /resume, /active,
28
28
  * /order.
29
29
  *
30
- * 第一性原理(2026-09-10 窄屏重设计):分页栏要同时承载「页码信息」和「翻页
30
+ * 第一性原理(窄屏):分页栏要同时承载「页码信息」和「翻页
31
31
  * 控件」两类需求。旧布局把两者塞进同一行 `column_set`(文案 weighted 4 : 输入
32
32
  * weighted 2),手机窄屏下 auto 按钮先占走固有宽度,剩余宽度按 4:2 切分后文案
33
33
  * 列只有百来像素、输入框只有几十像素——文案折行、输入框窄到没法点。
@@ -104,8 +104,8 @@ export declare function searchBar(opts: {
104
104
  *
105
105
  * 旧口径是 `events.slice(-max)`:按**原始事件条数**截尾。工具往返(bash 调用 →
106
106
  * job 登记 → 结果)一段就能把窗口占满,于是重返会话的用户真正需要的两条信息
107
- * ——最后一句我说了什么、最后一句它答了什么——被挤出窗口。2026-10-19 实测:
108
- * 自动恢复卡 5 个面板全是 bash 调用,一条用户消息、一条 assistant 正文都没有。
107
+ * ——最后一句我说了什么、最后一句它答了什么——被挤出窗口:自动恢复卡 5 个
108
+ * 面板全是 bash 调用,一条用户消息、一条 assistant 正文都没有。
109
109
  *
110
110
  * 契约:先取最新 `max` 条,再把最后一条 `user` 与最后一条 `assistant`(正文)
111
111
  * 不在窗口内时补进来——补位时淘汰最早的**非锚点**事件。输出保持时间顺序,