lark-remote 0.5.12 → 0.5.14

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 (172) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/agent-kinds.d.ts +27 -0
  5. package/dist/agent-kinds.js +38 -0
  6. package/dist/bridge/approval-actions.d.ts +95 -0
  7. package/dist/bridge/approval-actions.js +104 -0
  8. package/dist/bridge/approval-coordinator.d.ts +1 -0
  9. package/dist/bridge/approval-coordinator.js +12 -24
  10. package/dist/bridge/final-usage.d.ts +8 -17
  11. package/dist/bridge/final-usage.js +1 -18
  12. package/dist/bridge/index.d.ts +52 -38
  13. package/dist/bridge/index.js +255 -433
  14. package/dist/bridge/queue-manager.d.ts +25 -1
  15. package/dist/bridge/queue-manager.js +179 -160
  16. package/dist/bridge/run-approval.d.ts +16 -0
  17. package/dist/bridge/run-approval.js +41 -0
  18. package/dist/bridge/run-usage.d.ts +32 -0
  19. package/dist/bridge/run-usage.js +87 -0
  20. package/dist/bridge/session-writeback.d.ts +19 -0
  21. package/dist/bridge/session-writeback.js +36 -0
  22. package/dist/card/approval-render.d.ts +3 -2
  23. package/dist/card/approval-render.js +3 -2
  24. package/dist/card/bash-card-session.js +2 -5
  25. package/dist/{router → card}/card-helpers.d.ts +7 -7
  26. package/dist/{router → card}/card-helpers.js +8 -8
  27. package/dist/card/card-session.d.ts +6 -2
  28. package/dist/card/card-session.js +28 -28
  29. package/dist/card/card-shared.d.ts +4 -4
  30. package/dist/card/card-shared.js +4 -10
  31. package/dist/card/collapsible.js +3 -2
  32. package/dist/card/final-answer-overflow.d.ts +1 -0
  33. package/dist/card/final-answer-overflow.js +46 -0
  34. package/dist/card/run-card-session.d.ts +2 -2
  35. package/dist/card/run-card-session.js +4 -9
  36. package/dist/card/run-renderer.d.ts +14 -0
  37. package/dist/card/run-renderer.js +126 -23
  38. package/dist/card/run-state.d.ts +40 -51
  39. package/dist/card/run-state.js +96 -34
  40. package/dist/card/usage-format.d.ts +30 -0
  41. package/dist/{router → card}/usage-format.js +3 -10
  42. package/dist/clone.js +6 -1
  43. package/dist/config/codex-config.js +4 -4
  44. package/dist/config/index.d.ts +18 -20
  45. package/dist/config/index.js +6 -20
  46. package/dist/config/pi-config.js +6 -4
  47. package/dist/connector/index.d.ts +2 -4
  48. package/dist/connector/index.js +7 -11
  49. package/dist/{runner/dsh → dsh}/auth.js +3 -4
  50. package/dist/dsh/home.d.ts +2 -0
  51. package/dist/dsh/home.js +8 -0
  52. package/dist/dsh/protocol.d.ts +9 -0
  53. package/dist/dsh/protocol.js +61 -0
  54. package/dist/inbound/turn-assembler.js +6 -0
  55. package/dist/index.js +25 -414
  56. package/dist/message-handlers.d.ts +26 -0
  57. package/dist/message-handlers.js +275 -0
  58. package/dist/model-catalog.d.ts +14 -0
  59. package/dist/model-catalog.js +25 -0
  60. package/dist/platform/path.d.ts +9 -0
  61. package/dist/platform/path.js +18 -0
  62. package/dist/router/active-run-usage.d.ts +4 -0
  63. package/dist/router/active-run-usage.js +12 -0
  64. package/dist/router/card-action-payload.d.ts +1 -1
  65. package/dist/router/card-action-payload.js +1 -1
  66. package/dist/router/commands/ls.d.ts +9 -0
  67. package/dist/router/commands/ls.js +243 -0
  68. package/dist/router/commands/order.d.ts +12 -0
  69. package/dist/router/commands/order.js +178 -0
  70. package/dist/router/commands/resume.d.ts +48 -0
  71. package/dist/router/commands/resume.js +246 -0
  72. package/dist/router/commands/ws.d.ts +15 -0
  73. package/dist/router/commands/ws.js +189 -0
  74. package/dist/router/config/claude.js +5 -4
  75. package/dist/router/config/codex.js +2 -6
  76. package/dist/router/config/common/render.d.ts +5 -1
  77. package/dist/router/config/common/render.js +6 -13
  78. package/dist/router/config/common/turn-idle.d.ts +2 -0
  79. package/dist/router/config/common/turn-idle.js +9 -0
  80. package/dist/router/config/dsh.d.ts +1 -1
  81. package/dist/router/config/index.d.ts +1 -1
  82. package/dist/router/config/kimi.js +2 -6
  83. package/dist/router/config/opencode.js +2 -6
  84. package/dist/router/config/types.d.ts +11 -3
  85. package/dist/router/index.d.ts +55 -33
  86. package/dist/router/index.js +233 -1376
  87. package/dist/router/paging.d.ts +13 -0
  88. package/dist/router/paging.js +20 -0
  89. package/dist/router/path-validation.d.ts +27 -0
  90. package/dist/router/path-validation.js +45 -0
  91. package/dist/runner/agent-choices-common.d.ts +1 -1
  92. package/dist/runner/claude/runner.d.ts +23 -6
  93. package/dist/runner/claude/runner.js +6 -20
  94. package/dist/runner/claude/session.js +1 -1
  95. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  96. package/dist/runner/codex/app-server/translator.d.ts +13 -0
  97. package/dist/runner/codex/app-server/translator.js +114 -151
  98. package/dist/runner/common/acp/base-acp-runner.d.ts +3 -1
  99. package/dist/runner/common/acp/base-translator.d.ts +3 -8
  100. package/dist/runner/common/acp/base-translator.js +16 -7
  101. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  102. package/dist/runner/common/jsonrpc/transport.js +17 -7
  103. package/dist/runner/common/spawning-runner.js +10 -4
  104. package/dist/runner/common/turn-started.d.ts +2 -0
  105. package/dist/runner/common/turn-started.js +10 -0
  106. package/dist/runner/dsh/client.d.ts +2 -10
  107. package/dist/runner/dsh/client.js +13 -220
  108. package/dist/runner/dsh/daemon.d.ts +0 -2
  109. package/dist/runner/dsh/daemon.js +9 -7
  110. package/dist/runner/dsh/errors.d.ts +5 -0
  111. package/dist/runner/dsh/errors.js +11 -0
  112. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  113. package/dist/runner/dsh/exit-cancel.js +2 -2
  114. package/dist/runner/dsh/inflight.d.ts +0 -2
  115. package/dist/runner/dsh/inflight.js +1 -1
  116. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  117. package/dist/runner/dsh/mux-stream.js +176 -0
  118. package/dist/runner/dsh/runner.d.ts +21 -1
  119. package/dist/runner/dsh/runner.js +76 -73
  120. package/dist/runner/dsh/translator.d.ts +1 -11
  121. package/dist/runner/dsh/translator.js +1 -1
  122. package/dist/runner/index.d.ts +1 -1
  123. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  124. package/dist/runner/kimi/acp/runner.js +83 -60
  125. package/dist/runner/kimi/acp/translator.js +1 -12
  126. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  127. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  128. package/dist/runner/opencode/acp/runner.d.ts +3 -1
  129. package/dist/runner/opencode/acp/translator.js +1 -12
  130. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  131. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  132. package/dist/runner/pi/rpc/translator.js +2 -7
  133. package/dist/runner/probe.d.ts +1 -1
  134. package/dist/runner/probe.js +10 -9
  135. package/dist/runner/registry.d.ts +2 -20
  136. package/dist/runner/registry.js +0 -32
  137. package/dist/runner/types.d.ts +37 -155
  138. package/dist/runner/types.js +4 -24
  139. package/dist/session/claude/session-index.d.ts +11 -4
  140. package/dist/session/claude/session-index.js +34 -10
  141. package/dist/session/claude/session-reader.d.ts +1 -1
  142. package/dist/session/claude/sessions.d.ts +2 -2
  143. package/dist/session/claude/sessions.js +41 -26
  144. package/dist/session/codex/rollout-reader.d.ts +1 -1
  145. package/dist/session/codex/rollout-reader.js +14 -13
  146. package/dist/session/codex/sessions.d.ts +1 -1
  147. package/dist/session/codex/sessions.js +4 -2
  148. package/dist/session/common/jsonl.js +7 -1
  149. package/dist/session/common/pagination.d.ts +2 -0
  150. package/dist/session/common/pagination.js +1 -1
  151. package/dist/session/common/recency.d.ts +6 -0
  152. package/dist/session/common/recency.js +7 -5
  153. package/dist/session/common/two-pass.d.ts +1 -1
  154. package/dist/session/common/usage-accumulator.d.ts +1 -1
  155. package/dist/session/dsh/sessions.d.ts +1 -1
  156. package/dist/session/dsh/sessions.js +37 -12
  157. package/dist/session/kimi/sessions.d.ts +1 -1
  158. package/dist/session/kimi/sessions.js +37 -10
  159. package/dist/session/opencode/sessions.d.ts +1 -1
  160. package/dist/session/opencode/sessions.js +53 -48
  161. package/dist/session/pi/sessions.d.ts +1 -1
  162. package/dist/session/pi/sessions.js +23 -9
  163. package/dist/session/registry.d.ts +2 -1
  164. package/dist/session/session-store.d.ts +1 -2
  165. package/dist/session/session-store.js +14 -7
  166. package/dist/session-contract.d.ts +178 -0
  167. package/dist/session-contract.js +9 -0
  168. package/package.json +1 -1
  169. package/dist/router/usage-format.d.ts +0 -60
  170. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  171. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  172. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -1,4 +1,4 @@
1
- import type { AgentKind } from '../runner/types.js';
1
+ import type { AgentKind } from '../agent-kinds.js';
2
2
  /**
3
3
  * 入队时刻快照:当前 defaultAgent + 该 agent 的 sessionId(无 session 则 undefined)。
4
4
  * 唯一捕获点:排队消息在入队时刻把 agent+session 钉进 AgentBinding,随任务闭包带到执行时刻
@@ -221,8 +221,32 @@ export declare class QueueManager {
221
221
  /**
222
222
  * Enqueue a task into the workspace-level serial queue.
223
223
  * Each cwd has its own serial queue for parallel execution across workspaces.
224
+ *
225
+ * 三段各自的职责:`registerQueuedTask`(排队元数据 + 满队拒收)、
226
+ * `beginQueuedTask`(接跑时刻的撤销检查/替换闭包/占用登记)、本方法
227
+ * (promise 链拼接与 settle 记账)。红线:链的拼接必须同步完成,
228
+ * 中途不得 await(否则两条入队会乱序)。
224
229
  */
225
230
  enqueue(cwd: string, task: () => Promise<void>, opts?: EnqueueOptions): void;
231
+ /**
232
+ * 登记排队元数据(展示卡数据源 + 计数 + 排队卡)。
233
+ *
234
+ * @returns false = 队列已满被拒收(不入队、不占执行槽位),调用方必须
235
+ * 直接把任务链跳过。背压红线:满队时拒收而不是继续 append,否则 promise
236
+ * 链与闭包在消息洪峰下无界增长,还会多打一张排队卡。
237
+ */
238
+ private registerQueuedTask;
239
+ /** 满队拒收卡(best-effort 送达,失败只记日志:任务已经被拒,不能再吞一次反馈)。 */
240
+ private sendQueueFullRejection;
241
+ /**
242
+ * 轮到本任务执行:撤销检查 → 消费一次性替换闭包 → 排队卡转「执行中」→
243
+ * 重新武装计数与 lane 占用登记 → 跑任务闭包。
244
+ *
245
+ * @returns 任务闭包的 promise;被撤销时返回 undefined(本槽位什么都不跑)。
246
+ */
247
+ private beginQueuedTask;
248
+ /** 取走并清除该任务的一次性替换闭包(消费即销毁)。 */
249
+ private takeTaskReplacement;
226
250
  /**
227
251
  * Execute a task immediately without going through the queue.
228
252
  * Used for / commands that should respond immediately.
@@ -144,6 +144,11 @@ export class QueueManager {
144
144
  /**
145
145
  * Enqueue a task into the workspace-level serial queue.
146
146
  * Each cwd has its own serial queue for parallel execution across workspaces.
147
+ *
148
+ * 三段各自的职责:`registerQueuedTask`(排队元数据 + 满队拒收)、
149
+ * `beginQueuedTask`(接跑时刻的撤销检查/替换闭包/占用登记)、本方法
150
+ * (promise 链拼接与 settle 记账)。红线:链的拼接必须同步完成,
151
+ * 中途不得 await(否则两条入队会乱序)。
147
152
  */
148
153
  enqueue(cwd, task, opts) {
149
154
  // Guard: reject non-function tasks that would poison the queue chain
@@ -156,77 +161,9 @@ export class QueueManager {
156
161
  // interrupt credit granted by `resetExecutingCount` can only be consumed
157
162
  // by the interrupted task's own settle.
158
163
  const slotId = ++this.slotCounter;
159
- // Track this task in the queue for display card
160
164
  const taskMeta = opts?.taskMeta;
161
- let messagePreview;
162
- if (taskMeta) {
163
- messagePreview = taskMeta.messagePreview;
164
- const queuedTask = {
165
- userId: taskMeta.userId,
166
- chatId: taskMeta.chatId,
167
- messageId: taskMeta.messageId,
168
- feishuReplyTo: taskMeta.feishuReplyTo,
169
- cwd,
170
- timestamp: Date.now(),
171
- messagePreview,
172
- editable: taskMeta.editable,
173
- binding: taskMeta.binding,
174
- };
175
- // Check if there are tasks waiting BEFORE adding this one
176
- const currentExecutingCount = this.pendingOrExecutingCount.get(cwd) ?? 0;
177
- const currentQueueLength = this.queuedTasks.get(cwd)?.length ?? 0;
178
- const hasWaitingTasks = currentExecutingCount > 0 || currentQueueLength > 0;
179
- // bound the waiting backlog. When the queue is full, reject the
180
- // task with a visible card instead of appending it (which would grow
181
- // the promise chain + closures unboundedly under a message flood and
182
- // fire another queue card). Do NOT increment pendingOrExecutingCount —
183
- // a rejected task never executes, so it must not occupy a slot.
184
- if (currentQueueLength >= MAX_WAITING_QUEUE) {
185
- getLogger().warn(`[queue-manager] queue full cwd=${cwd} depth=${currentQueueLength} rejecting task messageId=${taskMeta.messageId}`);
186
- void this.sendCard(taskMeta.chatId, cardWithHeader({
187
- template: 'red',
188
- title: '🚫 队列已满',
189
- elements: [
190
- {
191
- tag: 'div',
192
- text: {
193
- tag: 'lark_md',
194
- content: `当前工作目录已有 ${MAX_WAITING_QUEUE} 条消息排队等待,为防止积压已拒收本条消息。请等待队列消化后重发,或用 \`/stop\` 清空当前任务。`,
195
- },
196
- },
197
- ],
198
- }), { replyTo: taskMeta.feishuReplyTo ?? taskMeta.messageId }).catch((err) => {
199
- getLogger().error('[queue-manager] failed to send queue-full rejection card:', err);
200
- });
201
- return;
202
- }
203
- // Add to cwd queue list
204
- let taskList = this.queuedTasks.get(cwd);
205
- if (!taskList) {
206
- taskList = [];
207
- this.queuedTasks.set(cwd, taskList);
208
- }
209
- // 身份不变量 tripwire:同一 cwd 内 queue key 必须唯一(一次入队动作一个
210
- // key,铸造点 router.mintQueueTaskKey)。撞 key 时数组(追加)与 taskIndex
211
- // (覆盖)必然漂移,位置显示/撤销/立即执行/卡片更新会串到别的条目上——
212
- // 2026-10-08 线上缺陷就是这么长出来的。这里不改行为,只留证据。
213
- if (this.indexGet(cwd, queuedTask.messageId)) {
214
- getLogger().warn(`[queue-manager] duplicate queue key cwd=${cwd} messageId=${queuedTask.messageId} — ` +
215
- 'queue identity must be minted per enqueue action (see router.mintQueueTaskKey)');
216
- }
217
- taskList.push(queuedTask);
218
- this.indexAdd(cwd, queuedTask);
219
- // Increment executing count SYNCHRONOUSLY so subsequent enqueues see it
220
- this.pendingOrExecutingCount.set(cwd, currentExecutingCount + 1);
221
- // Only send the queue card if the task actually has to wait.
222
- if (hasWaitingTasks) {
223
- void this.sendQueueStatusCard(cwd, taskMeta.chatId, taskMeta.messageId, messagePreview,
224
- // Reply to the real Feishu message id when the queue key is an
225
- // internal key (order.exec); otherwise messageId IS the Feishu id.
226
- taskMeta.feishuReplyTo);
227
- }
228
- getLogger().debug(`[queue-manager] enqueue task queued cwd=${cwd} queueCard=${hasWaitingTasks} executing=${currentExecutingCount + 1} queueLen=${currentQueueLength + 1}`);
229
- }
165
+ if (taskMeta && !this.registerQueuedTask(cwd, taskMeta))
166
+ return;
230
167
  // Get or create the queue for this cwd
231
168
  let queue = this.queues.get(cwd);
232
169
  if (!queue) {
@@ -236,97 +173,8 @@ export class QueueManager {
236
173
  getLogger().debug(`[queue-manager] enqueue cwd=${cwd}`);
237
174
  // Capture messageId for cancellation guard
238
175
  const messageId = taskMeta?.messageId;
239
- // messagePreview already captured above (outside taskMeta block)
240
176
  const newQueue = queue
241
- .then(() => {
242
- getLogger().debug(`[queue-manager] task begin cwd=${cwd} messageId=${messageId}`);
243
- // Live preview for the executing card: read from the QueuedTask at
244
- // begin time, not the enqueue-closure `taskMeta` (which is frozen).
245
- // `updateQueuedTaskMessage` (queue.edit/queue.input) mutates the live
246
- // task, so a task edited while queued must show the edited content
247
- // when it naturally starts.
248
- let livePreview = taskMeta?.messagePreview ?? '';
249
- // Check if this task was cancelled before executing
250
- if (messageId) {
251
- // O(1) index lookup replaces the old `find` + `findIndex`
252
- // double O(N) scan. The ordered array still drives removal (splice
253
- // preserves queue order for position display); the index stays in
254
- // sync via indexRemove.
255
- const task = this.indexGet(cwd, messageId);
256
- if (!task) {
257
- getLogger().debug(`[queue-manager] task skipped (cancelled) cwd=${cwd} messageId=${messageId}`);
258
- return;
259
- }
260
- livePreview = task.messagePreview;
261
- // Remove this task's metadata by messageId
262
- const taskList = this.queuedTasks.get(cwd);
263
- const idx = taskList?.findIndex((t) => t.messageId === messageId) ?? -1;
264
- if (taskList && idx >= 0) {
265
- taskList.splice(idx, 1);
266
- }
267
- this.indexRemove(cwd, messageId);
268
- // Cancellation check passed and the task has been removed: it is
269
- // about to run, so record it as began for queue.immediate feedback.
270
- // Bounded: ids are only consulted for the current immediate target,
271
- // so pruning the oldest entries is safe.
272
- if (this.beganMessageIds.size >= 10_000) {
273
- const oldest = this.beganMessageIds.values().next().value;
274
- if (oldest !== undefined)
275
- this.beganMessageIds.delete(oldest);
276
- }
277
- this.beganMessageIds.add(messageId);
278
- }
279
- // Consume any one-shot replacement closure registered by
280
- // `setTaskReplacement` (queue.edit + queue.immediate). The replacement
281
- // swaps in the edited content at this task's original queue slot, so
282
- // tasks queued behind it still run after it. Deleted here (one-shot)
283
- // so a stale closure can never run on a later task.
284
- let replacement;
285
- if (messageId) {
286
- const workspaceReplacements = this.taskReplacements.get(cwd);
287
- replacement = workspaceReplacements?.get(messageId);
288
- if (replacement) {
289
- workspaceReplacements?.delete(messageId);
290
- if (workspaceReplacements && workspaceReplacements.size === 0) {
291
- this.taskReplacements.delete(cwd);
292
- }
293
- }
294
- }
295
- // Update queue card to "executing" status BEFORE running the task.
296
- // This must happen after the cancellation check: a skipped/cancelled
297
- // task must keep its "❌ 已撤销" card, not be flipped to executing.
298
- if (messageId) {
299
- void this.updateQueueCardToExecuting(cwd, messageId, livePreview, true);
300
- }
301
- // Re-arm the pending/executing count for every task that is about to
302
- // run. `resetExecutingCount` (external interrupt) zeroes the count,
303
- // and the interrupted task's settle consumes the skip-credit — so a
304
- // task enqueued BEFORE the interrupt can begin with count 0. Without
305
- // this, a running task is invisible to later enqueues and they
306
- // silently skip the "⏳ 消息排队中" card. This applies regardless of
307
- // taskMeta: resetExecutingCount can clear the count for any cwd,
308
- // and a task that resumes after an interrupt must re-arm even when it
309
- // carries no metadata.
310
- const currentCount = this.pendingOrExecutingCount.get(cwd) ?? 0;
311
- if (currentCount < 1) {
312
- this.pendingOrExecutingCount.set(cwd, 1);
313
- getLogger().debug(`[queue-manager] re-armed pendingOrExecutingCount cwd=${cwd} (interrupt resume)`);
314
- }
315
- // Mark this task as the lane occupant for its whole execution period
316
- // before running it. `resetExecutingCount` reads the slot id to grant
317
- // the interrupt credit to exactly this task; the settle removes the
318
- // record. `kind`/`label` let a later message's queue card name what it
319
- // is waiting for instead of reporting "前面还有 0 条".
320
- this.executingInfo.set(cwd, {
321
- slotId,
322
- messageId,
323
- kind: taskMeta?.kind ?? 'message',
324
- label: laneOccupantLabel(taskMeta?.kind ?? 'message', livePreview),
325
- preview: livePreview,
326
- startedAt: Date.now(),
327
- });
328
- return replacement ? replacement() : task();
329
- })
177
+ .then(() => this.beginQueuedTask(cwd, slotId, messageId, task, taskMeta))
330
178
  .then(() => {
331
179
  getLogger().debug(`[queue-manager] task end cwd=${cwd}`);
332
180
  // beganMessageIds entry intentionally retained: sticky by design
@@ -343,6 +191,177 @@ export class QueueManager {
343
191
  });
344
192
  this.queues.set(cwd, newQueue);
345
193
  }
194
+ /**
195
+ * 登记排队元数据(展示卡数据源 + 计数 + 排队卡)。
196
+ *
197
+ * @returns false = 队列已满被拒收(不入队、不占执行槽位),调用方必须
198
+ * 直接把任务链跳过。背压红线:满队时拒收而不是继续 append,否则 promise
199
+ * 链与闭包在消息洪峰下无界增长,还会多打一张排队卡。
200
+ */
201
+ registerQueuedTask(cwd, taskMeta) {
202
+ // Check if there are tasks waiting BEFORE adding this one
203
+ const currentExecutingCount = this.pendingOrExecutingCount.get(cwd) ?? 0;
204
+ const currentQueueLength = this.queuedTasks.get(cwd)?.length ?? 0;
205
+ const hasWaitingTasks = currentExecutingCount > 0 || currentQueueLength > 0;
206
+ if (currentQueueLength >= MAX_WAITING_QUEUE) {
207
+ getLogger().warn(`[queue-manager] queue full cwd=${cwd} depth=${currentQueueLength} rejecting task messageId=${taskMeta.messageId}`);
208
+ void this.sendQueueFullRejection(taskMeta);
209
+ return false;
210
+ }
211
+ const queuedTask = {
212
+ userId: taskMeta.userId,
213
+ chatId: taskMeta.chatId,
214
+ messageId: taskMeta.messageId,
215
+ feishuReplyTo: taskMeta.feishuReplyTo,
216
+ cwd,
217
+ timestamp: Date.now(),
218
+ messagePreview: taskMeta.messagePreview,
219
+ editable: taskMeta.editable,
220
+ binding: taskMeta.binding,
221
+ };
222
+ // Add to cwd queue list
223
+ let taskList = this.queuedTasks.get(cwd);
224
+ if (!taskList) {
225
+ taskList = [];
226
+ this.queuedTasks.set(cwd, taskList);
227
+ }
228
+ // 身份不变量 tripwire:同一 cwd 内 queue key 必须唯一(一次入队动作一个
229
+ // key,铸造点 router.mintQueueTaskKey)。撞 key 时数组(追加)与 taskIndex
230
+ // (覆盖)必然漂移,位置显示/撤销/立即执行/卡片更新会串到别的条目上——
231
+ // 2026-10-08 线上缺陷就是这么长出来的。这里不改行为,只留证据。
232
+ if (this.indexGet(cwd, queuedTask.messageId)) {
233
+ getLogger().warn(`[queue-manager] duplicate queue key cwd=${cwd} messageId=${queuedTask.messageId} — ` +
234
+ 'queue identity must be minted per enqueue action (see router.mintQueueTaskKey)');
235
+ }
236
+ taskList.push(queuedTask);
237
+ this.indexAdd(cwd, queuedTask);
238
+ // Increment executing count SYNCHRONOUSLY so subsequent enqueues see it
239
+ this.pendingOrExecutingCount.set(cwd, currentExecutingCount + 1);
240
+ // Only send the queue card if the task actually has to wait.
241
+ if (hasWaitingTasks) {
242
+ void this.sendQueueStatusCard(cwd, taskMeta.chatId, taskMeta.messageId, taskMeta.messagePreview,
243
+ // Reply to the real Feishu message id when the queue key is an
244
+ // internal key (order.exec); otherwise messageId IS the Feishu id.
245
+ taskMeta.feishuReplyTo);
246
+ }
247
+ getLogger().debug(`[queue-manager] enqueue task queued cwd=${cwd} queueCard=${hasWaitingTasks} executing=${currentExecutingCount + 1} queueLen=${currentQueueLength + 1}`);
248
+ return true;
249
+ }
250
+ /** 满队拒收卡(best-effort 送达,失败只记日志:任务已经被拒,不能再吞一次反馈)。 */
251
+ async sendQueueFullRejection(taskMeta) {
252
+ await this.sendCard(taskMeta.chatId, cardWithHeader({
253
+ template: 'red',
254
+ title: '🚫 队列已满',
255
+ elements: [
256
+ {
257
+ tag: 'div',
258
+ text: {
259
+ tag: 'lark_md',
260
+ content: `当前工作目录已有 ${MAX_WAITING_QUEUE} 条消息排队等待,为防止积压已拒收本条消息。请等待队列消化后重发,或用 \`/stop\` 清空当前任务。`,
261
+ },
262
+ },
263
+ ],
264
+ }), { replyTo: taskMeta.feishuReplyTo ?? taskMeta.messageId }).catch((err) => {
265
+ getLogger().error('[queue-manager] failed to send queue-full rejection card:', err);
266
+ });
267
+ }
268
+ /**
269
+ * 轮到本任务执行:撤销检查 → 消费一次性替换闭包 → 排队卡转「执行中」→
270
+ * 重新武装计数与 lane 占用登记 → 跑任务闭包。
271
+ *
272
+ * @returns 任务闭包的 promise;被撤销时返回 undefined(本槽位什么都不跑)。
273
+ */
274
+ beginQueuedTask(cwd, slotId, messageId, task, taskMeta) {
275
+ getLogger().debug(`[queue-manager] task begin cwd=${cwd} messageId=${messageId}`);
276
+ // Live preview for the executing card: read from the QueuedTask at
277
+ // begin time, not the enqueue-closure `taskMeta` (which is frozen).
278
+ // `updateQueuedTaskMessage` (queue.edit/queue.input) mutates the live
279
+ // task, so a task edited while queued must show the edited content
280
+ // when it naturally starts.
281
+ let livePreview = taskMeta?.messagePreview ?? '';
282
+ // Check if this task was cancelled before executing
283
+ if (messageId) {
284
+ // O(1) index lookup replaces the old `find` + `findIndex`
285
+ // double O(N) scan. The ordered array still drives removal (splice
286
+ // preserves queue order for position display); the index stays in
287
+ // sync via indexRemove.
288
+ const queued = this.indexGet(cwd, messageId);
289
+ if (!queued) {
290
+ getLogger().debug(`[queue-manager] task skipped (cancelled) cwd=${cwd} messageId=${messageId}`);
291
+ return undefined;
292
+ }
293
+ livePreview = queued.messagePreview;
294
+ // Remove this task's metadata by messageId
295
+ const taskList = this.queuedTasks.get(cwd);
296
+ const idx = taskList?.findIndex((t) => t.messageId === messageId) ?? -1;
297
+ if (taskList && idx >= 0) {
298
+ taskList.splice(idx, 1);
299
+ }
300
+ this.indexRemove(cwd, messageId);
301
+ // Cancellation check passed and the task has been removed: it is
302
+ // about to run, so record it as began for queue.immediate feedback.
303
+ // Bounded: ids are only consulted for the current immediate target,
304
+ // so pruning the oldest entries is safe.
305
+ if (this.beganMessageIds.size >= 10_000) {
306
+ const oldest = this.beganMessageIds.values().next().value;
307
+ if (oldest !== undefined)
308
+ this.beganMessageIds.delete(oldest);
309
+ }
310
+ this.beganMessageIds.add(messageId);
311
+ }
312
+ // Consume any one-shot replacement closure registered by
313
+ // `setTaskReplacement` (queue.edit + queue.immediate). The replacement
314
+ // swaps in the edited content at this task's original queue slot, so
315
+ // tasks queued behind it still run after it. Deleted here (one-shot)
316
+ // so a stale closure can never run on a later task.
317
+ const replacement = messageId ? this.takeTaskReplacement(cwd, messageId) : undefined;
318
+ // Update queue card to "executing" status BEFORE running the task.
319
+ // This must happen after the cancellation check: a skipped/cancelled
320
+ // task must keep its "❌ 已撤销" card, not be flipped to executing.
321
+ if (messageId) {
322
+ void this.updateQueueCardToExecuting(cwd, messageId, livePreview, true);
323
+ }
324
+ // Re-arm the pending/executing count for every task that is about to
325
+ // run. `resetExecutingCount` (external interrupt) zeroes the count,
326
+ // and the interrupted task's settle consumes the skip-credit — so a
327
+ // task enqueued BEFORE the interrupt can begin with count 0. Without
328
+ // this, a running task is invisible to later enqueues and they
329
+ // silently skip the "⏳ 消息排队中" card. This applies regardless of
330
+ // taskMeta: resetExecutingCount can clear the count for any cwd,
331
+ // and a task that resumes after an interrupt must re-arm even when it
332
+ // carries no metadata.
333
+ const currentCount = this.pendingOrExecutingCount.get(cwd) ?? 0;
334
+ if (currentCount < 1) {
335
+ this.pendingOrExecutingCount.set(cwd, 1);
336
+ getLogger().debug(`[queue-manager] re-armed pendingOrExecutingCount cwd=${cwd} (interrupt resume)`);
337
+ }
338
+ // Mark this task as the lane occupant for its whole execution period
339
+ // before running it. `resetExecutingCount` reads the slot id to grant
340
+ // the interrupt credit to exactly this task; the settle removes the
341
+ // record. `kind`/`label` let a later message's queue card name what it
342
+ // is waiting for instead of reporting "前面还有 0 条".
343
+ this.executingInfo.set(cwd, {
344
+ slotId,
345
+ messageId,
346
+ kind: taskMeta?.kind ?? 'message',
347
+ label: laneOccupantLabel(taskMeta?.kind ?? 'message', livePreview),
348
+ preview: livePreview,
349
+ startedAt: Date.now(),
350
+ });
351
+ return replacement ? replacement() : task();
352
+ }
353
+ /** 取走并清除该任务的一次性替换闭包(消费即销毁)。 */
354
+ takeTaskReplacement(cwd, messageId) {
355
+ const workspaceReplacements = this.taskReplacements.get(cwd);
356
+ const replacement = workspaceReplacements?.get(messageId);
357
+ if (replacement) {
358
+ workspaceReplacements?.delete(messageId);
359
+ if (workspaceReplacements && workspaceReplacements.size === 0) {
360
+ this.taskReplacements.delete(cwd);
361
+ }
362
+ }
363
+ return replacement;
364
+ }
346
365
  /**
347
366
  * Execute a task immediately without going through the queue.
348
367
  * Used for / commands that should respond immediately.
@@ -0,0 +1,16 @@
1
+ import { type AppConfig } from '../config/index.js';
2
+ import type { AgentKind } from '../agent-kinds.js';
3
+ import type { Runner } from '../runner/index.js';
4
+ import type { RunCardSession } from '../card/run-card-session.js';
5
+ import { ApprovalCoordinator } from './approval-coordinator.js';
6
+ export declare function remindPendingApproval(connector: {
7
+ removeReactionByEmoji(messageId: string, emoji: string): Promise<void>;
8
+ addReaction(messageId: string, emoji: string): Promise<void>;
9
+ }, messageId: string, runId: string): void;
10
+ export declare function createRunApprovalCoordinator(opts: {
11
+ runner: Runner;
12
+ cardSession: RunCardSession;
13
+ config: AppConfig;
14
+ runId: string;
15
+ agentKind: AgentKind;
16
+ }): ApprovalCoordinator;
@@ -0,0 +1,41 @@
1
+ import { DEFAULT_APPROVAL_TIMEOUT_MS } from '../config/index.js';
2
+ import { ApprovalCoordinator } from './approval-coordinator.js';
3
+ import { getLogger } from '../logger/index.js';
4
+ import { errorMessage } from './final-usage.js';
5
+ export function remindPendingApproval(connector, messageId, runId) {
6
+ void (async () => {
7
+ try {
8
+ await connector.removeReactionByEmoji(messageId, 'Typing');
9
+ }
10
+ catch (err) {
11
+ getLogger().warn(`[lark-remote] remove Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
12
+ }
13
+ try {
14
+ await connector.addReaction(messageId, 'Typing');
15
+ }
16
+ catch (err) {
17
+ getLogger().warn(`[lark-remote] re-add Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
18
+ }
19
+ })();
20
+ }
21
+ export function createRunApprovalCoordinator(opts) {
22
+ return new ApprovalCoordinator({
23
+ approvalTimeoutMs: opts.agentKind === 'claude'
24
+ ? (opts.config.claude?.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS)
25
+ : DEFAULT_APPROVAL_TIMEOUT_MS,
26
+ responder: async (requestId, response) => {
27
+ const runner = opts.runner;
28
+ if (typeof runner.respondApproval === 'function') {
29
+ await runner.respondApproval(requestId, response);
30
+ }
31
+ },
32
+ interruptTurn: async () => {
33
+ await opts.runner.stop();
34
+ },
35
+ pushToCard: async (events) => {
36
+ for (const event of events)
37
+ await opts.cardSession.push(event);
38
+ },
39
+ });
40
+ }
41
+ //# sourceMappingURL=run-approval.js.map
@@ -0,0 +1,32 @@
1
+ import type { ResultEvent } from '../runner/types.js';
2
+ import type { FinalUsageSnapshot } from './final-usage.js';
3
+ import type { UsageSnapshot } from '../session-contract.js';
4
+ export type UsageAuthority = 'live' | 'jsonl';
5
+ /**
6
+ * 一张 run 的用量记账:live 侧(result 事件 / compact_boundary)先收着,
7
+ * run 结束时与 jsonl 快照按权威来源合成一份 `UsageSnapshot`。
8
+ *
9
+ * live 侧字段清单只有 `UsageSnapshot` 一份:此前这里是 19 个 `final*` 平铺
10
+ * 字段逐字段搬运,加一个 usage 字段要改三处(§9.21 的一致性靠人记)。
11
+ */
12
+ export declare class RunUsageCollector {
13
+ /** live 侧观察值(jsonl 读不到时的兜底)。 */
14
+ private contextLength?;
15
+ private liveInput?;
16
+ private liveOutput?;
17
+ private liveContextLimit?;
18
+ private liveCacheRead?;
19
+ private liveCacheCreation?;
20
+ private liveTotal?;
21
+ private liveReasoningTokens?;
22
+ /** finalize() 之后的最终口径;meta() 的唯一数据源。 */
23
+ private resolved;
24
+ resultNotSuccess: boolean;
25
+ get liveInputTokens(): number | undefined;
26
+ get liveOutputTokens(): number | undefined;
27
+ observeCompaction(postTokens: number): void;
28
+ observeResult(event: ResultEvent): void;
29
+ finalize(finalUsage: FinalUsageSnapshot | undefined, authority: UsageAuthority | undefined): void;
30
+ meta(): UsageSnapshot;
31
+ logSummary(runId: string): string;
32
+ }
@@ -0,0 +1,87 @@
1
+ import { normalizeResultUsage } from '../runner/common/result-usage.js';
2
+ /**
3
+ * 一张 run 的用量记账:live 侧(result 事件 / compact_boundary)先收着,
4
+ * run 结束时与 jsonl 快照按权威来源合成一份 `UsageSnapshot`。
5
+ *
6
+ * live 侧字段清单只有 `UsageSnapshot` 一份:此前这里是 19 个 `final*` 平铺
7
+ * 字段逐字段搬运,加一个 usage 字段要改三处(§9.21 的一致性靠人记)。
8
+ */
9
+ export class RunUsageCollector {
10
+ /** live 侧观察值(jsonl 读不到时的兜底)。 */
11
+ contextLength;
12
+ liveInput;
13
+ liveOutput;
14
+ liveContextLimit;
15
+ liveCacheRead;
16
+ liveCacheCreation;
17
+ liveTotal;
18
+ liveReasoningTokens;
19
+ /** finalize() 之后的最终口径;meta() 的唯一数据源。 */
20
+ resolved = {};
21
+ resultNotSuccess = false;
22
+ get liveInputTokens() {
23
+ return this.liveInput;
24
+ }
25
+ get liveOutputTokens() {
26
+ return this.liveOutput;
27
+ }
28
+ observeCompaction(postTokens) {
29
+ this.contextLength = postTokens;
30
+ }
31
+ observeResult(event) {
32
+ this.resultNotSuccess = event.subtype !== 'success';
33
+ if (!event.usage)
34
+ return;
35
+ const usage = normalizeResultUsage(event.usage);
36
+ if (!this.contextLength)
37
+ this.contextLength = usage.contextLength;
38
+ this.liveInput = usage.inputTokens;
39
+ this.liveOutput = usage.outputTokens;
40
+ this.liveContextLimit = usage.contextLimit;
41
+ if (usage.cacheReadTokens !== undefined)
42
+ this.liveCacheRead = usage.cacheReadTokens;
43
+ if (usage.cacheCreationTokens !== undefined) {
44
+ this.liveCacheCreation = usage.cacheCreationTokens;
45
+ }
46
+ if (usage.totalTokens !== undefined)
47
+ this.liveTotal = usage.totalTokens;
48
+ if (usage.reasoningTokens !== undefined)
49
+ this.liveReasoningTokens = usage.reasoningTokens;
50
+ }
51
+ finalize(finalUsage, authority) {
52
+ const resolved = {
53
+ // 从 jsonl 快照整体展开,新增 UsageSnapshot 字段无需再改这里。
54
+ ...finalUsage,
55
+ contextLength: finalUsage?.contextLength ?? this.contextLength,
56
+ contextLimit: finalUsage?.contextLimit ?? this.liveContextLimit,
57
+ compactCount: finalUsage?.compactCount,
58
+ // result 事件里的 cache/total 两条路径都要带:jsonl 读得到时被覆盖,
59
+ // 读不到(或走 live flow scope)时它就是最终数字。
60
+ cacheReadTokens: this.liveCacheRead,
61
+ cacheCreationTokens: this.liveCacheCreation,
62
+ totalTokens: this.liveTotal,
63
+ reasoningTokens: this.liveReasoningTokens,
64
+ };
65
+ const useLiveFlowScope = authority !== 'jsonl' && this.liveInput !== undefined && this.liveOutput !== undefined;
66
+ if (useLiveFlowScope) {
67
+ resolved.inputTokens = this.liveInput;
68
+ resolved.outputTokens = this.liveOutput;
69
+ this.resolved = resolved;
70
+ return;
71
+ }
72
+ resolved.inputTokens = finalUsage?.inputTokens ?? this.liveInput;
73
+ resolved.outputTokens = finalUsage?.outputTokens ?? this.liveOutput;
74
+ resolved.cacheReadTokens = finalUsage?.cacheReadTokens ?? this.liveCacheRead;
75
+ resolved.cacheCreationTokens = finalUsage?.cacheCreationTokens ?? this.liveCacheCreation;
76
+ resolved.totalTokens = finalUsage?.totalTokens ?? this.liveTotal;
77
+ this.resolved = resolved;
78
+ }
79
+ meta() {
80
+ return this.resolved;
81
+ }
82
+ logSummary(runId) {
83
+ const meta = this.meta();
84
+ return `[lark-remote] final usage runId=${runId} contextLength=${meta.contextLength} contextLimit=${meta.contextLimit ?? 'undefined'} compactCount=${meta.compactCount ?? 'undefined'} cacheRead=${meta.cacheReadTokens ?? 'undefined'} cacheCreate=${meta.cacheCreationTokens ?? 'undefined'}`;
85
+ }
86
+ }
87
+ //# sourceMappingURL=run-usage.js.map
@@ -0,0 +1,19 @@
1
+ import type { AgentKind } from '../agent-kinds.js';
2
+ import type { TurnStartedEvent } from '../runner/index.js';
3
+ import type { SessionStore } from '../session/index.js';
4
+ interface InitEvent {
5
+ session_id: string;
6
+ cwd?: string;
7
+ }
8
+ export interface WritebackContext {
9
+ sessionStore: SessionStore;
10
+ userId: string;
11
+ agentKind: AgentKind;
12
+ cwd: string;
13
+ sessionEpochAtStart: number;
14
+ runId: string;
15
+ }
16
+ export declare function writeBackInitSession(ctx: WritebackContext, event: InitEvent): void;
17
+ export declare function writeBackTurnStarted(ctx: WritebackContext, event: TurnStartedEvent): boolean;
18
+ export declare function writeBackResultSession(ctx: WritebackContext, sessionId: string | undefined): void;
19
+ export {};
@@ -0,0 +1,36 @@
1
+ import { getLogger } from '../logger/index.js';
2
+ function pointerMoved(ctx) {
3
+ return ctx.sessionStore.getSessionEpoch(ctx.userId, ctx.agentKind) !== ctx.sessionEpochAtStart;
4
+ }
5
+ export function writeBackInitSession(ctx, event) {
6
+ if (pointerMoved(ctx)) {
7
+ getLogger().info(`[lark-remote] system.init write-back skipped: session pointer moved since run start runId=${ctx.runId} sessionId=${event.session_id}`);
8
+ return;
9
+ }
10
+ const eventCwd = typeof event.cwd === 'string' ? event.cwd : '';
11
+ const hasCwd = !!ctx.sessionStore.getCwd(ctx.userId);
12
+ const realCwd = eventCwd.length > 0 ? eventCwd : ctx.cwd;
13
+ if (hasCwd && eventCwd && eventCwd !== ctx.cwd) {
14
+ getLogger().info(`[lark-remote] system.init: cwd=${ctx.cwd}, session cwd=${eventCwd}`);
15
+ ctx.sessionStore.setSessionIdAndSessionCwd(ctx.userId, ctx.agentKind, event.session_id, realCwd);
16
+ return;
17
+ }
18
+ const bootstrapSessionCwd = !hasCwd && eventCwd.length > 0 ? eventCwd : undefined;
19
+ ctx.sessionStore.setSessionIdAndCwd(ctx.userId, ctx.agentKind, event.session_id, realCwd, bootstrapSessionCwd);
20
+ }
21
+ export function writeBackTurnStarted(ctx, event) {
22
+ if (pointerMoved(ctx)) {
23
+ getLogger().info(`[lark-remote] turn_started write-back skipped: session pointer moved since run start runId=${ctx.runId} sessionId=${event.threadId}`);
24
+ return false;
25
+ }
26
+ ctx.sessionStore.setSessionIdAndCwd(ctx.userId, ctx.agentKind, event.threadId, ctx.cwd);
27
+ getLogger().info(`[lark-remote] turn_started session write-back runId=${ctx.runId} sessionId=${event.threadId}`);
28
+ return true;
29
+ }
30
+ export function writeBackResultSession(ctx, sessionId) {
31
+ if (!sessionId || pointerMoved(ctx))
32
+ return;
33
+ ctx.sessionStore.setSessionIdAndCwd(ctx.userId, ctx.agentKind, sessionId, ctx.cwd);
34
+ getLogger().info(`[lark-remote] result session write-back runId=${ctx.runId} sessionId=${sessionId}`);
35
+ }
36
+ //# sourceMappingURL=session-writeback.js.map