lark-remote 0.5.0 → 0.5.2

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.
@@ -11,6 +11,18 @@ import { displayName } from '../platform/path.js';
11
11
  * backlog, not throughput. Owner is a single user, so 50 is ample headroom.
12
12
  */
13
13
  const MAX_WAITING_QUEUE = 50;
14
+ /** 排队卡「正在执行」一行用的展示标签。 */
15
+ function laneOccupantLabel(kind, preview) {
16
+ switch (kind) {
17
+ case 'compact':
18
+ return '🗜 Compact';
19
+ case 'command':
20
+ // 卡片动作的预览形如 'card action: config.save',标签里去掉前缀更短。
21
+ return `📋 ${preview.replace(/^card action:\s*/, '')}`;
22
+ default:
23
+ return '💬 消息';
24
+ }
25
+ }
14
26
  /**
15
27
  * QueueManager handles the per-workspace serial processing queue.
16
28
  * Manages task queuing, cancellation, and queue status card rendering.
@@ -46,18 +58,23 @@ export class QueueManager {
46
58
  /**
47
59
  * Monotonic counter minting a unique slot id per enqueued task. A slot
48
60
  * identifies one task's execution period for the interrupt bookkeeping in
49
- * `executingSlot`/`interruptedSlots` — replacing the unowned skip-credit
61
+ * `executingInfo`/`interruptedSlots` — replacing the unowned skip-credit
50
62
  * counter, which any settle could consume and which therefore leaked a
51
63
  * re-armed count after repeated resets.
52
64
  */
53
65
  slotCounter = 0;
54
66
  /**
55
- * Per-workspace slot id of the task currently executing. Set in the begin
56
- * path right before the task runs; deleted when that task's settle fires.
57
- * `resetExecutingCount` reads it to bind the interrupt credit to exactly
58
- * the interrupted task's slot.
67
+ * Per-workspace record of the task currently executing — the lane occupant.
68
+ * Set in the begin path right before the task runs; deleted by that task's
69
+ * settle. Carries both the slot id (`resetExecutingCount` binds the
70
+ * interrupt credit to exactly the interrupted task's slot) and the display
71
+ * info the queue card needs to name what a waiting message waits for.
72
+ *
73
+ * 2026-10-09 线上:一次 Compact 占着 lane,新消息的排队卡却写「位置: 第 1
74
+ * 位 / 前面还有: 0 条消息」——开卡判据看 lane(含正在执行者),位置文案只看
75
+ * 等待队列,两个口径的差集正是这条记录。
59
76
  */
60
- executingSlot = new Map();
77
+ executingInfo = new Map();
61
78
  /**
62
79
  * Per-workspace set of slot ids whose task was interrupted by
63
80
  * `resetExecutingCount`. When such a slot settles, its decrement is skipped
@@ -183,6 +200,14 @@ export class QueueManager {
183
200
  taskList = [];
184
201
  this.queuedTasks.set(cwd, taskList);
185
202
  }
203
+ // 身份不变量 tripwire:同一 cwd 内 queue key 必须唯一(一次入队动作一个
204
+ // key,铸造点 router.mintQueueTaskKey)。撞 key 时数组(追加)与 taskIndex
205
+ // (覆盖)必然漂移,位置显示/撤销/立即执行/卡片更新会串到别的条目上——
206
+ // 2026-10-08 线上缺陷就是这么长出来的。这里不改行为,只留证据。
207
+ if (this.indexGet(cwd, queuedTask.messageId)) {
208
+ getLogger().warn(`[queue-manager] duplicate queue key cwd=${cwd} messageId=${queuedTask.messageId} — ` +
209
+ 'queue identity must be minted per enqueue action (see router.mintQueueTaskKey)');
210
+ }
186
211
  taskList.push(queuedTask);
187
212
  this.indexAdd(cwd, queuedTask);
188
213
  // Increment executing count SYNCHRONOUSLY so subsequent enqueues see it
@@ -281,10 +306,18 @@ export class QueueManager {
281
306
  this.pendingOrExecutingCount.set(cwd, 1);
282
307
  getLogger().debug(`[queue-manager] re-armed pendingOrExecutingCount cwd=${cwd} (interrupt resume)`);
283
308
  }
284
- // Mark this task's slot as the current execution period before running
285
- // it. `resetExecutingCount` reads this marker to grant the interrupt
286
- // credit to exactly this task; the settle removes the marker.
287
- this.executingSlot.set(cwd, slotId);
309
+ // Mark this task as the lane occupant for its whole execution period
310
+ // before running it. `resetExecutingCount` reads the slot id to grant
311
+ // the interrupt credit to exactly this task; the settle removes the
312
+ // record. `kind`/`label` let a later message's queue card name what it
313
+ // is waiting for instead of reporting "前面还有 0 条".
314
+ this.executingInfo.set(cwd, {
315
+ slotId,
316
+ kind: taskMeta?.kind ?? 'message',
317
+ label: laneOccupantLabel(taskMeta?.kind ?? 'message', livePreview),
318
+ preview: livePreview,
319
+ startedAt: Date.now(),
320
+ });
288
321
  return replacement ? replacement() : task();
289
322
  })
290
323
  .then(() => {
@@ -325,6 +358,20 @@ export class QueueManager {
325
358
  workspaceReplacements.set(messageId, task);
326
359
  getLogger().debug(`[queue-manager] set task replacement cwd=${cwd} messageId=${messageId}`);
327
360
  }
361
+ /**
362
+ * 排队卡头部。只有前面确实还有等待中的消息时才说「排队」;前面为空时
363
+ * 用户等的是当前正在执行的任务(例如 Compact),用「等待当前任务结束」
364
+ * 表达才不会与「前面还有 0 条」自相矛盾(2026-10-09 线上实例)。
365
+ */
366
+ queueCardHeader(tasksAhead) {
367
+ return {
368
+ template: 'orange',
369
+ title: {
370
+ tag: 'plain_text',
371
+ content: tasksAhead > 0 ? '⏳ 消息排队中' : '⏳ 等待当前任务结束',
372
+ },
373
+ };
374
+ }
328
375
  /** Send a queue status card showing current queue position and actions. */
329
376
  async sendQueueStatusCard(cwd, chatId, replyToMessageId, messagePreview,
330
377
  /** Feishu reply target when `replyToMessageId` (the queue key) is an
@@ -338,10 +385,7 @@ export class QueueManager {
338
385
  const card = {
339
386
  schema: '2.0',
340
387
  config: { wide_screen_mode: true },
341
- header: {
342
- template: 'orange',
343
- title: { tag: 'plain_text', content: '⏳ 消息排队中' },
344
- },
388
+ header: this.queueCardHeader(tasksAhead),
345
389
  body: {
346
390
  elements: this.buildQueueStatusCardElements(cwd, actualPosition, tasksAhead, replyToMessageId, messagePreview),
347
391
  },
@@ -379,25 +423,48 @@ export class QueueManager {
379
423
  /** Build queue status card elements. */
380
424
  buildQueueStatusCardElements(cwd, actualPosition, tasksAhead, messageId, messagePreview) {
381
425
  const workspaceName = displayName(cwd);
382
- const isRunning = this.isWorkspaceRunning(cwd);
426
+ const occupant = this.executingInfo.get(cwd);
383
427
  const elements = [
384
428
  {
385
429
  tag: 'div',
386
- text: {
387
- tag: 'lark_md',
388
- content: `**当前 Workspace:** \`${workspaceName}\`\n**位置:** 第 ${actualPosition} 位\n**前面还有:** ${tasksAhead} 条消息在排队`,
389
- },
430
+ text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
390
431
  },
391
- { tag: 'hr' },
392
- {
432
+ ];
433
+ // 「队列位置 / 前面还有」只在前面确实还有等待中的消息时渲染。前面为空时
434
+ // 这两个数是「第 1 位 / 0 条」,与「排队中」字面矛盾;此时挡住用户的只有
435
+ // 正在执行的任务,由下一段的占用者说明(若占用者是普通消息则不额外渲染)。
436
+ if (tasksAhead > 0) {
437
+ elements.push({
393
438
  tag: 'div',
394
439
  text: {
395
440
  tag: 'lark_md',
396
- content: `💡 消息将按顺序执行…当前正在处理: ${isRunning ? '有任务运行中' : '空闲'}`,
441
+ content: `**队列位置:** 第 ${actualPosition} 位\n**前面还有:** ${tasksAhead} 条消息`,
397
442
  },
398
- },
399
- { tag: 'hr' },
400
- ];
443
+ });
444
+ }
445
+ if (occupant) {
446
+ // 只有可能挡住队列、需要用户归因的占用者才自报身份。「正在执行: 💬 消息」
447
+ // 对普通消息占用者没有信息量——用户刚发的就是消息,「💡 消息将按顺序执行…」
448
+ // 也只是复述排队本身,一并省去(2026-10-11)。压缩额外说明何时轮到用户。
449
+ if (occupant.kind !== 'message') {
450
+ elements.push({
451
+ tag: 'div',
452
+ text: { tag: 'lark_md', content: `**正在执行:** ${occupant.label}` },
453
+ });
454
+ }
455
+ if (occupant.kind === 'compact') {
456
+ elements.push({
457
+ tag: 'div',
458
+ text: { tag: 'lark_md', content: '💡 压缩结束后将按顺序执行你的消息' },
459
+ });
460
+ }
461
+ }
462
+ else {
463
+ // 防御分支:开卡判据是 lane 非空,所以「有排队卡但没有占用者」说明计数
464
+ // 与占用者记录漂移了。不改行为,只留证据(与 duplicate queue key 同口径)。
465
+ getLogger().warn(`[queue-manager] queue card without lane occupant cwd=${cwd} tasksAhead=${tasksAhead}`);
466
+ }
467
+ elements.push({ tag: 'hr' });
401
468
  // Show message preview with edit button (only if not executing AND the
402
469
  // task is editable). One-shot card actions like Compact enqueue with
403
470
  // editable=false: their preview is not user-editable text, so the ✏️ 编辑
@@ -423,8 +490,10 @@ export class QueueManager {
423
490
  }
424
491
  elements.push({ tag: 'hr' });
425
492
  }
426
- // Action buttons
427
- elements.push(...this.buildQueueActionButtons(cwd, messageId, false));
493
+ // Action buttons. Compact 占着 lane 时「⚡ 立即执行」会中断压缩
494
+ // (interruptCurrentRun 遍历 activeRuns,压缩也注册在里面),按钮置灰并
495
+ // 换文案说明;执行层 handleQueueImmediate 再做一次同样的守卫兜住旧卡片。
496
+ elements.push(...this.buildQueueActionButtons(cwd, messageId, false, occupant?.kind === 'compact' ? { immediateLabel: '压缩完成后按顺序执行' } : undefined));
428
497
  return elements;
429
498
  }
430
499
  /**
@@ -436,8 +505,12 @@ export class QueueManager {
436
505
  * both. Centralizing the pair here eliminates the 3-way duplication
437
506
  * between `buildQueueStatusCardElements`, `updateQueueCardToExecuting`,
438
507
  * and `updateQueueCardToCancelled` (Clean Code).
508
+ *
509
+ * `opts.immediateLabel`(仅 pending 卡用)在 lane 被 Compact 占用时替换
510
+ * 「⚡ 立即执行」并强制置灰:点击它会中断正在跑的压缩。「❌ 撤销」不参与,
511
+ * 它只移除等待中的消息,不触碰 Compact。
439
512
  */
440
- buildQueueActionButtons(cwd, messageId, disabled) {
513
+ buildQueueActionButtons(cwd, messageId, disabled, opts) {
441
514
  return [
442
515
  {
443
516
  tag: 'button',
@@ -448,9 +521,9 @@ export class QueueManager {
448
521
  },
449
522
  {
450
523
  tag: 'button',
451
- text: { tag: 'plain_text', content: '⚡ 立即执行' },
524
+ text: { tag: 'plain_text', content: opts?.immediateLabel ?? '⚡ 立即执行' },
452
525
  type: 'primary',
453
- disabled,
526
+ disabled: disabled || opts?.immediateLabel !== undefined,
454
527
  behaviors: [{ type: 'callback', value: { cmd: 'queue.immediate', cwd, messageId } }],
455
528
  },
456
529
  ];
@@ -633,10 +706,7 @@ export class QueueManager {
633
706
  return {
634
707
  schema: '2.0',
635
708
  config: { wide_screen_mode: true },
636
- header: {
637
- template: 'orange',
638
- title: { tag: 'plain_text', content: '⏳ 消息排队中' },
639
- },
709
+ header: this.queueCardHeader(tasksAhead),
640
710
  body: {
641
711
  elements: this.buildQueueStatusCardElements(cwd, actualPosition, tasksAhead, messageId, newMessagePreview),
642
712
  },
@@ -650,7 +720,7 @@ export class QueueManager {
650
720
  * id minted at enqueue time. Three cases:
651
721
  *
652
722
  * 1. The slot is the workspace's current executing slot → its execution
653
- * period ends (`executingSlot` marker removed).
723
+ * period ends (`executingInfo` record removed).
654
724
  * 2. The slot is in `interruptedSlots` (its task was interrupted by
655
725
  * `resetExecutingCount`) → skip the decrement: the reset already zeroed
656
726
  * the count, so a stale decrement could wrongly zero it again while a
@@ -658,8 +728,8 @@ export class QueueManager {
658
728
  * 3. Otherwise → normal decrement.
659
729
  */
660
730
  decrementExecutingCount(cwd, slotId) {
661
- if (this.executingSlot.get(cwd) === slotId) {
662
- this.executingSlot.delete(cwd);
731
+ if (this.executingInfo.get(cwd)?.slotId === slotId) {
732
+ this.executingInfo.delete(cwd);
663
733
  }
664
734
  const interrupted = this.interruptedSlots.get(cwd);
665
735
  if (interrupted?.has(slotId)) {
@@ -693,14 +763,14 @@ export class QueueManager {
693
763
  * `expectedSlot` binds the reset to the task that was actually interrupted:
694
764
  * the caller captures the executing slot BEFORE stopping the runner, and
695
765
  * the stop window may outlive the interrupted task's settle (the chain can
696
- * advance to a NEW task, which then owns `executingSlot`). When the current
766
+ * advance to a NEW task, which then owns `executingInfo`). When the current
697
767
  * slot no longer matches the interrupted task's slot, the interrupted task
698
768
  * already decremented normally — resetting now would zero the count of the
699
769
  * running successor and mark ITS slot interrupted, hiding it from the
700
770
  * queue card.
701
771
  */
702
772
  resetExecutingCount(cwd, expectedSlot) {
703
- const currentSlot = this.executingSlot.get(cwd);
773
+ const currentSlot = this.executingInfo.get(cwd)?.slotId;
704
774
  if (currentSlot !== expectedSlot) {
705
775
  getLogger().debug(`[queue-manager] resetExecutingCount skip (stopped task settled, slot advanced) cwd=${cwd} expectedSlot=${expectedSlot} currentSlot=${currentSlot ?? 'none'}`);
706
776
  return;
@@ -733,7 +803,17 @@ export class QueueManager {
733
803
  * The slot id of the task currently executing in this cwd (if any).
734
804
  */
735
805
  getExecutingSlot(cwd) {
736
- return this.executingSlot.get(cwd);
806
+ return this.executingInfo.get(cwd)?.slotId;
807
+ }
808
+ /**
809
+ * The lane occupant's display info for this cwd (if any). Queue cards read
810
+ * it to name what a waiting message is waiting for; `handleQueueImmediate`
811
+ * reads it to refuse interrupting a running Compact. Returns a copy so
812
+ * callers cannot mutate the live record.
813
+ */
814
+ getExecutingInfo(cwd) {
815
+ const info = this.executingInfo.get(cwd);
816
+ return info ? { ...info } : undefined;
737
817
  }
738
818
  }
739
819
  //# sourceMappingURL=queue-manager.js.map
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * CLI 引导层(bootstrap):在 import 应用模块图之前决定运行时(bun/node)并拉起它。
4
4
  *
5
- *XX * 缺一不可:
5
+ * **这是全仓唯一被认可的 spawn 收口例外**(即 AGENTS.md「子进程 spawn 只走
6
+ * platform/spawn.ts」红线的显式豁免)。三个理由缺一不可:
6
7
  * 1. 时序:preflight(Node 版本守卫、-v/-h/--advanced-help)必须在 import 应用
7
8
  * 模块图之前跑完——dist/cli.js 刻意不使用 ES2020+ 语法以便在 node 12 上仍
8
9
  * 可被解析,而 dist/index.js 是 ES2022;先 import platform/spawn 就等于先把
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { probeAllAgents } from './runner/probe.js';
9
9
  import { warmCodexCatalogCache } from './config/codex-config.js';
10
10
  import { SessionReaderRegistry, SessionStore } from './session/index.js';
11
11
  import { ClaudeSessionReader, CodexSessionReader, OpencodeSessionReader, PiSessionReader, KimiSessionReader, DshSessionReader, } from './session/index.js';
12
- import { CommandRouter, isImmediateAction, DIRECT_RETURN_CMDS, SESSION_MUTATING_ACTION_CMDS, } from './router/index.js';
12
+ import { CommandRouter, isImmediateAction, DIRECT_RETURN_CMDS, SESSION_MUTATING_ACTION_CMDS, COMPACT_ACTION_CMDS, } from './router/index.js';
13
13
  import { dispatchOrderExecForQueue } from './router/order-exec-dispatch.js';
14
14
  import { buildCardActionFullValue } from './router/card-action-payload.js';
15
15
  import { actionFeedbackText } from './router/card-action-feedback.js';
@@ -629,10 +629,21 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
629
629
  taskMeta: {
630
630
  userId,
631
631
  chatId,
632
- messageId,
632
+ // 队列身份 key 必须与「这一次点击」1:1:同一张卡片可点 N 次
633
+ // (Compact 尤其明显),复用卡片 messageId 会让同 key 的多条排队
634
+ // 任务在 queuedTasks/taskIndex/queueCardMessages 三处 lookup 里互相
635
+ // 顶替(2026-10-08 线上:连点 Compact 三次 → 三张卡都显示「第 1 位」、
636
+ // 两张永久停在「等待中」,只压缩一次)。
637
+ messageId: router.mintQueueTaskKey('card', actionValue.cmd),
638
+ // 卡片 messageId 仍然要留着当回复目标:排队卡要回复到被点击的卡片,
639
+ // 内部 key 不是合法飞书 id(replyTo 用它会 400)。
640
+ feishuReplyTo: messageId,
633
641
  messagePreview: `card action: ${actionValue.cmd}`,
642
+ // lane 占用者的展示类型:压缩类动作在排队卡上自报身份
643
+ // (「正在执行: 🗜 Compact」),并据此禁用「立即执行」。
644
+ kind: COMPACT_ACTION_CMDS.has(actionValue.cmd) ? 'compact' : 'command',
634
645
  // Compact 是单向操作,排队卡不允许编辑(编辑预览无意义)。
635
- editable: actionValue.cmd !== 'compact' && actionValue.cmd !== 'resume.compact',
646
+ editable: !COMPACT_ACTION_CMDS.has(actionValue.cmd),
636
647
  },
637
648
  });
638
649
  }
@@ -114,8 +114,20 @@ export declare const DIRECT_RETURN_CMDS: ReadonlySet<string>;
114
114
  * 不在此列——只有卡片动作绕过了该分支。
115
115
  */
116
116
  export declare const SESSION_MUTATING_ACTION_CMDS: ReadonlySet<string>;
117
+ /**
118
+ * 会产生压缩工作的卡片动作(单源)。这类条目占用 lane 时必须自报身份:
119
+ * 排队卡写明「正在执行: 🗜 Compact」而不是「前面还有 0 条」,并且期间禁用
120
+ * 「立即执行」——该按钮走 interruptCurrentRun,会连带中断正在跑的压缩
121
+ * (2026-10-09 线上:Compact 占 lane 期间新消息的排队卡自相矛盾,且按钮
122
+ * 一点就会杀掉压缩)。同时它们也不允许编辑(预览不是用户文本)。
123
+ */
124
+ export declare const COMPACT_ACTION_CMDS: ReadonlySet<string>;
117
125
  export declare class CommandRouter {
118
- /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
126
+ /**
127
+ * Valid agent kinds — single source of truth for resume.use / resume.page /
128
+ * cmdResume. 名单本体在 `runner/types.ts` 的 AGENT_KINDS(与 AgentKind 类型
129
+ * 同源),这里只做别名,避免人手抄第二份。
130
+ */
119
131
  static readonly VALID_AGENTS: readonly ["claude", "codex", "opencode", "pi", "kimi", "dsh"];
120
132
  private sessionStore;
121
133
  /** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
@@ -150,13 +162,28 @@ export declare class CommandRouter {
150
162
  /** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
151
163
  pendingConfig: AppConfig | null;
152
164
  /**
153
- * Monotonic counter minting unique internal keys for order.exec enqueue
154
- * actions. One order card can be clicked many times, so the Feishu card
155
- * messageId is 1:N with enqueue actions and must not be reused as the queue
156
- * dedup key. Date.now() alone is unsafe (two synchronous calls can share a
157
- * millisecond); the counter guarantees uniqueness regardless of timing.
165
+ * 已见过的 /config 卡片 messageId(有界,最近的在最后)。
166
+ *
167
+ * 草稿(pendingConfig)是全局一份:/config 文本命令重开卡片时沿用同一份草稿
168
+ * (这是既有语义,不改)。但**视图**是每张卡一份 —— 只刷新被点击的那张,其余
169
+ * 卡片就停在旧视图上:用户在旧卡上看到的和点保存实际提交的是两套内容
170
+ * (2026-10-08 发现的缺陷)。这里记住所有交互过的 config 卡,任何一次编辑/保存
171
+ * 后把草稿刷到每张卡,保证「看到的就是会保存的」。
172
+ */
173
+ private configCardIds;
174
+ /**
175
+ * 排队条目身份 key 的唯一铸造计数器(order.exec 与卡片动作共用)。
176
+ *
177
+ * 一张飞书卡片可以被点击 N 次,卡片 messageId 与入队动作是 1:N;而排队
178
+ * 条目的身份必须与「一次入队动作」1:1(queuedTasks 的定位、queueCardMessages
179
+ * 的卡片更新、taskIndex 的取消判据全部按这个 key 做 lookup)。复用卡片 id
180
+ * 会让同 key 的多条任务互相顶替:位置显示命中最早那条兄弟、第一条开始时删掉
181
+ * 索引导致其余兄弟被判「已撤销」静默丢弃(2026-10-08 线上:连点 Compact
182
+ * 三次 → 三张排队卡都显示「第 1 位」、两张永久停在「等待中」,只压缩一次)。
183
+ *
184
+ * Date.now() 不够(两次同步调用可落在同一毫秒);计数器与时间无关,恒唯一。
158
185
  */
159
- private orderExecKeyCounter;
186
+ private queueTaskKeyCounter;
160
187
  /**
161
188
  * Per-user sort preference for /ws list (memory-only, not persisted).
162
189
  * Default 'recent' (most recently used first), can toggle to 'alpha'.
@@ -349,6 +376,15 @@ export declare class CommandRouter {
349
376
  orderText: string;
350
377
  internalKey: string;
351
378
  } | null;
379
+ /**
380
+ * 铸造一个排队条目身份 key(唯一铸造点)。
381
+ *
382
+ * 调用方:order.exec 文本钩子(resolveOrderExecForQueue)与入口层
383
+ * (index.ts)的非即时卡片动作入队。卡片 messageId 只能当「回复目标」
384
+ * (`taskMeta.feishuReplyTo`),不得当队列 key —— 理由见
385
+ * `queueTaskKeyCounter` 字段注释。
386
+ */
387
+ mintQueueTaskKey(kind: string, id?: string): string;
352
388
  /**
353
389
  * 单源:重建 order/ws 列表卡(不投递,card 进 callback 响应体用)。
354
390
  * `q` 只服务 ws 卡的关键词筛选;order 卡无此功能,不透传。
@@ -452,6 +488,19 @@ export declare class CommandRouter {
452
488
  private deliverFileToFeishu;
453
489
  /** 确保 pendingConfig 已初始化(从当前 config 克隆) */
454
490
  ensurePendingConfig(): void;
491
+ /**
492
+ * 记住一张 config 卡片 messageId(有界:超限丢最早的一张)。
493
+ * 无 messageId 的调用(测试/文本路径)不登记。
494
+ */
495
+ private rememberConfigCard;
496
+ /**
497
+ * 把当前草稿刷到所有已知 config 卡片上(含本次点击的那张)。
498
+ *
499
+ * 被点击的那张走 `updateCardInPlace`(失败 fallback 发新卡,保证点击必有
500
+ * 反馈);其余兄弟卡走 `patchCard`(纯 best-effort,失败即从集合剔除,不再
501
+ * 重复打无效 PATCH,也不会因为卡片过期而反复补发新卡)。
502
+ */
503
+ private refreshConfigCards;
455
504
  /**
456
505
  * 串行化 config.* 卡片回调(2026-07-04 修复 toggle 卡死 bug)。
457
506
  *
@@ -8,6 +8,7 @@ import { WorkspaceStore } from '../workspace/index.js';
8
8
  import { OrderStore } from '../order/index.js';
9
9
  import { resolveAlias } from '../order/alias-resolve.js';
10
10
  import { diffConfig, setNestedValue } from './config-diff.js';
11
+ import { AGENT_KINDS } from '../runner/types.js';
11
12
  import { getLogger } from '../logger/index.js';
12
13
  import { stripPlaceholders } from '../inbound/placeholder.js';
13
14
  import { clampInt } from '../common/clamp.js';
@@ -339,6 +340,14 @@ export const SESSION_MUTATING_ACTION_CMDS = new Set([
339
340
  'ws.use',
340
341
  'config.save',
341
342
  ]);
343
+ /**
344
+ * 会产生压缩工作的卡片动作(单源)。这类条目占用 lane 时必须自报身份:
345
+ * 排队卡写明「正在执行: 🗜 Compact」而不是「前面还有 0 条」,并且期间禁用
346
+ * 「立即执行」——该按钮走 interruptCurrentRun,会连带中断正在跑的压缩
347
+ * (2026-10-09 线上:Compact 占 lane 期间新消息的排队卡自相矛盾,且按钮
348
+ * 一点就会杀掉压缩)。同时它们也不允许编辑(预览不是用户文本)。
349
+ */
350
+ export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
342
351
  /** 单源:payload.offset → 钳位 offset(原先 9 处逐字副本)。 */
343
352
  function payloadOffset(value) {
344
353
  return Math.max(0, Math.trunc(Number(value.offset) || 0));
@@ -358,9 +367,18 @@ function searchQueryFrom(value) {
358
367
  }
359
368
  /** 卡片 cardAction payload 缺字段的统一报错文案(原先 12 处手写且已漂移出两种前缀)。 */
360
369
  const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
370
+ /**
371
+ * 记住并同步刷新的 config 卡片数上限。一次 /config 会话里用户实际会交互的
372
+ * 卡片只有最近一两张,5 张足够覆盖;无上限会让长跑进程的 Set 随卡片数增长。
373
+ */
374
+ const MAX_CONFIG_CARDS = 5;
361
375
  export class CommandRouter {
362
- /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
363
- static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
376
+ /**
377
+ * Valid agent kinds — single source of truth for resume.use / resume.page /
378
+ * cmdResume. 名单本体在 `runner/types.ts` 的 AGENT_KINDS(与 AgentKind 类型
379
+ * 同源),这里只做别名,避免人手抄第二份。
380
+ */
381
+ static VALID_AGENTS = AGENT_KINDS;
364
382
  sessionStore;
365
383
  /** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
366
384
  bridge;
@@ -394,13 +412,28 @@ export class CommandRouter {
394
412
  /** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
395
413
  pendingConfig = null;
396
414
  /**
397
- * Monotonic counter minting unique internal keys for order.exec enqueue
398
- * actions. One order card can be clicked many times, so the Feishu card
399
- * messageId is 1:N with enqueue actions and must not be reused as the queue
400
- * dedup key. Date.now() alone is unsafe (two synchronous calls can share a
401
- * millisecond); the counter guarantees uniqueness regardless of timing.
415
+ * 已见过的 /config 卡片 messageId(有界,最近的在最后)。
416
+ *
417
+ * 草稿(pendingConfig)是全局一份:/config 文本命令重开卡片时沿用同一份草稿
418
+ * (这是既有语义,不改)。但**视图**是每张卡一份 —— 只刷新被点击的那张,其余
419
+ * 卡片就停在旧视图上:用户在旧卡上看到的和点保存实际提交的是两套内容
420
+ * (2026-10-08 发现的缺陷)。这里记住所有交互过的 config 卡,任何一次编辑/保存
421
+ * 后把草稿刷到每张卡,保证「看到的就是会保存的」。
402
422
  */
403
- orderExecKeyCounter = 0;
423
+ configCardIds = new Set();
424
+ /**
425
+ * 排队条目身份 key 的唯一铸造计数器(order.exec 与卡片动作共用)。
426
+ *
427
+ * 一张飞书卡片可以被点击 N 次,卡片 messageId 与入队动作是 1:N;而排队
428
+ * 条目的身份必须与「一次入队动作」1:1(queuedTasks 的定位、queueCardMessages
429
+ * 的卡片更新、taskIndex 的取消判据全部按这个 key 做 lookup)。复用卡片 id
430
+ * 会让同 key 的多条任务互相顶替:位置显示命中最早那条兄弟、第一条开始时删掉
431
+ * 索引导致其余兄弟被判「已撤销」静默丢弃(2026-10-08 线上:连点 Compact
432
+ * 三次 → 三张排队卡都显示「第 1 位」、两张永久停在「等待中」,只压缩一次)。
433
+ *
434
+ * Date.now() 不够(两次同步调用可落在同一毫秒);计数器与时间无关,恒唯一。
435
+ */
436
+ queueTaskKeyCounter = 0;
404
437
  /**
405
438
  * Per-user sort preference for /ws list (memory-only, not persisted).
406
439
  * Default 'recent' (most recently used first), can toggle to 'alpha'.
@@ -765,6 +798,15 @@ export class CommandRouter {
765
798
  }, ctx);
766
799
  return;
767
800
  }
801
+ // Compact 占 lane 时拒绝「立即执行」。该动作会调 interruptCurrentRun,而它
802
+ // 遍历 activeRuns 时会一并中断正在跑的压缩(压缩也注册在 activeRuns 里)。
803
+ // 渲染层已把按钮置灰,这里是旧卡片的兜底:改动前渲染的卡仍带可点的按钮。
804
+ // 放在存在性检查之后:目标已失效时,上一条「不在队列中」的反馈更准确。
805
+ const laneOccupant = this.bridge.getQueueExecutingInfo(cwd);
806
+ if (laneOccupant?.kind === 'compact') {
807
+ await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
808
+ return;
809
+ }
768
810
  // 1.5. If the user edited the message, the original closure (captured at
769
811
  // enqueue time with stale content) must NOT run. Register a one-shot
770
812
  // replacement closure on the task's EXISTING queue slot BEFORE any await:
@@ -1348,9 +1390,23 @@ export class CommandRouter {
1348
1390
  this.orderStore.updateUsedAt(order.id);
1349
1391
  return {
1350
1392
  orderText: order.text,
1351
- internalKey: `order-${order.id}-${this.orderExecKeyCounter++}`,
1393
+ internalKey: this.mintQueueTaskKey('order', order.id),
1352
1394
  };
1353
1395
  }
1396
+ /**
1397
+ * 铸造一个排队条目身份 key(唯一铸造点)。
1398
+ *
1399
+ * 调用方:order.exec 文本钩子(resolveOrderExecForQueue)与入口层
1400
+ * (index.ts)的非即时卡片动作入队。卡片 messageId 只能当「回复目标」
1401
+ * (`taskMeta.feishuReplyTo`),不得当队列 key —— 理由见
1402
+ * `queueTaskKeyCounter` 字段注释。
1403
+ */
1404
+ mintQueueTaskKey(kind, id) {
1405
+ this.queueTaskKeyCounter += 1;
1406
+ return id === undefined
1407
+ ? `queue-${kind}-${this.queueTaskKeyCounter}`
1408
+ : `${kind}-${id}-${this.queueTaskKeyCounter}`;
1409
+ }
1354
1410
  /**
1355
1411
  * 单源:重建 order/ws 列表卡(不投递,card 进 callback 响应体用)。
1356
1412
  * `q` 只服务 ws 卡的关键词筛选;order 卡无此功能,不透传。
@@ -1742,6 +1798,44 @@ export class CommandRouter {
1742
1798
  this.pendingConfig = structuredClone(this.config);
1743
1799
  }
1744
1800
  }
1801
+ /**
1802
+ * 记住一张 config 卡片 messageId(有界:超限丢最早的一张)。
1803
+ * 无 messageId 的调用(测试/文本路径)不登记。
1804
+ */
1805
+ rememberConfigCard(messageId) {
1806
+ if (!messageId)
1807
+ return;
1808
+ this.configCardIds.delete(messageId);
1809
+ this.configCardIds.add(messageId);
1810
+ while (this.configCardIds.size > MAX_CONFIG_CARDS) {
1811
+ const oldest = this.configCardIds.values().next().value;
1812
+ if (oldest === undefined)
1813
+ break;
1814
+ this.configCardIds.delete(oldest);
1815
+ }
1816
+ }
1817
+ /**
1818
+ * 把当前草稿刷到所有已知 config 卡片上(含本次点击的那张)。
1819
+ *
1820
+ * 被点击的那张走 `updateCardInPlace`(失败 fallback 发新卡,保证点击必有
1821
+ * 反馈);其余兄弟卡走 `patchCard`(纯 best-effort,失败即从集合剔除,不再
1822
+ * 重复打无效 PATCH,也不会因为卡片过期而反复补发新卡)。
1823
+ */
1824
+ async refreshConfigCards(ctx) {
1825
+ this.rememberConfigCard(ctx.messageId);
1826
+ const card = this.buildConfigCard().card;
1827
+ if (!card)
1828
+ return;
1829
+ for (const messageId of [...this.configCardIds]) {
1830
+ if (messageId === ctx.messageId) {
1831
+ await this.bridge.updateCardInPlace(card, ctx);
1832
+ continue;
1833
+ }
1834
+ const ok = await this.bridge.patchCard(messageId, card);
1835
+ if (!ok)
1836
+ this.configCardIds.delete(messageId);
1837
+ }
1838
+ }
1745
1839
  /**
1746
1840
  * 串行化 config.* 卡片回调(2026-07-04 修复 toggle 卡死 bug)。
1747
1841
  *
@@ -1786,7 +1880,7 @@ export class CommandRouter {
1786
1880
  try {
1787
1881
  // 只改暂存区,不写盘;原地更新卡片而非发送新卡(2026-07-04)
1788
1882
  this.setNestedValue(this.pendingConfig, key, !boolVal);
1789
- await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
1883
+ await this.refreshConfigCards(ctx);
1790
1884
  }
1791
1885
  catch (err) {
1792
1886
  await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
@@ -1810,7 +1904,7 @@ export class CommandRouter {
1810
1904
  for (const patch of patches) {
1811
1905
  this.setNestedValue(this.pendingConfig, patch.key, patch.value);
1812
1906
  }
1813
- await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
1907
+ await this.refreshConfigCards(ctx);
1814
1908
  }
1815
1909
  catch (err) {
1816
1910
  await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
@@ -1841,7 +1935,7 @@ export class CommandRouter {
1841
1935
  for (const patch of patches) {
1842
1936
  this.setNestedValue(this.pendingConfig, patch.key, patch.value);
1843
1937
  }
1844
- await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
1938
+ await this.refreshConfigCards(ctx);
1845
1939
  }
1846
1940
  catch (err) {
1847
1941
  await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
@@ -1873,7 +1967,7 @@ export class CommandRouter {
1873
1967
  try {
1874
1968
  // host 可能已变更 → 重新预取 DSH 目录(内部按 host 缓存,未变不重复拉)
1875
1969
  await this.prefetchConfigCardCatalogs();
1876
- await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
1970
+ await this.refreshConfigCards(ctx);
1877
1971
  }
1878
1972
  catch (refreshErr) {
1879
1973
  getLogger().warn(`[router] config.save 卡片刷新失败(保存已成功): ${refreshErr.message}`);