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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.2] - 2026-10-11
9
+
10
+ ### 修复
11
+
12
+ - **codex 运行结束卡片的 token 统计不再翻倍**:codex app-server 实时上报的输入 token 按协议口径已包含命中缓存的部分,而卡片契约要求它表示「未命中缓存的输入」。入口没做减法时,Input 会等于整个上下文用量、缓存占比的分母被重复计数、Total 再被「总量与分项之和取大」抬高一倍(实测同一轮 36 万显示成 72 万)。现在实时通知与 jsonl 兜底读取两条入口统一减掉缓存部分
13
+ - **压缩卡片的「压缩前」不再显示摘要调用自身的用量**:codex 把生成摘要那次调用自己的 token 记录写在压缩事件之前,此前「压缩前」取的是紧贴压缩事件的那一条,于是把摘要调用的消耗当成压缩前的上下文窗口(实测 36 万的窗口显示成 20 万)。现在按压缩事件自带的摘要调用用量回退一条,取压缩前最后一次真实窗口;更早的、不带该字段的数据保持原读法
14
+ - **排队卡片不再复述「你正在排队」**:占用执行位的是普通消息时,卡片仍写着「正在执行: 💬 消息」并附一句「消息将按顺序执行…」,这两行对用户没有新信息。现在只有可能挡住队列、需要用户归因的任务(Compact、命令)才自报身份;Compact 占用时仍额外说明压缩结束后会按顺序执行你的消息
15
+
16
+ ### 变更
17
+
18
+ - **消除一处全量测试并发下的时序假红**:bash 退出清理用例在收到首个事件后立刻读取 shell 自己写入的 pid 文件,而重定向落盘可能晚于该事件,整套测试并发跑时会随机报文件不存在。改为与同一用例里另一处 pid 文件相同的有界等待。无行为变化
19
+
20
+ ## [0.5.1] - 2026-10-09
21
+
22
+ ### 修复
23
+
24
+ - **旧卡片上的 Compact 按钮点下去不再报「该任务已结束或不属于当前会话」**:Compact 按钮只带本轮 run 的标识,此前按「每个工作目录最新一条 run」反查会话,而 compact 动作走串行队列——排队期间后续 run 收尾会覆盖那一个槽位,旧卡片于是必然失效。现在每条 run 收尾时按自己的标识记下会话与 agent,按钮自证身份,排队多久都不失效;Compact 也改为按该 run 自己的目录反查,中途切过目录不会压错会话
25
+ - **同一会话的多条排队卡片不再互相顶掉**:队列条目此前复用飞书卡片的消息 id 当身份,同一会话里多条卡片撞同一个 key,先入队的那条被后面的覆盖、状态更新串到别的卡片上。改为每次入队铸造独立身份
26
+ - **排队卡片会说清「在等谁」**:Compact 与普通消息共用同一条串行队列,Compact 占着执行位时新消息卡片仍写「位置: 第 1 位 / 前面还有: 0 条」,看着像马上就能轮到自己。现在没有等待中的消息时头部改为「等待当前任务结束」、正文写明正在执行的是 Compact 并收起位置行;占用者是 Compact 时「⚡ 立即执行」在渲染层置灰、执行层再加一道守卫,「❌ 撤销」保持可用
27
+ - **codex 多 agent 会话不再把会话指针写坏**:codex app-server 会把子 agent 的开轮通知一起推过来,此前不判断归属,子线程的事件覆盖了本轮的线程标识,顶层会话指针被写成子线程标识——该值在会话索引里不是键,之后恢复会话报 `cannot resume an unloaded multi-agent v2 sub-agent`,`/restart` 也救不回来。现在通知按线程归属过滤;审批结算仍按请求标识走,子 agent 的审批照常可以回答
28
+ - **带 `dsh` 的卡片不再静默退回默认 agent**:agent 名单此前被手抄成多份并发生漂移,收敛为单一来源、校验由该名单派生,消除「用错 reader → 未找到 session」的静默降级
29
+
30
+ ### 变更
31
+
32
+ - **消除两处宿主环境相关的测试假红**:宿主带上本机没有的 locale(macOS 常见的 `LC_ALL=C.UTF-8`)时,bash 每次启动都往 stderr 打 setlocale 警告,让输出断言随机变红,测试 setup 阶段现在剔掉该变量(保留 UTF-8 解析所需项);JSON-RPC 退出计时用例的计时起点从进程启动改到收到 `initialize` 应答,慢速 Windows runner 上不再因启动耗时超过阈值而误红
33
+ - **英文架构文档补齐**:`docs/en/architecture/design.md` 增补与中文侧对应的五个章节,英中两侧不再不对称
34
+ - **注释与用例名清理**:延续上一版的清理,去掉剩余指向未发布文档的引用与评审编号,并修正清理过程中留下的残句。无行为变化
35
+
8
36
  ## [0.5.0] - 2026-10-07
9
37
 
10
38
  ### 新增
@@ -4,8 +4,8 @@ import type { SessionReaderRegistry, SessionStore } from '../session/index.js';
4
4
  import type { AppConfig } from '../config/index.js';
5
5
  import { type RunCardChannel } from '../card/run-card-session.js';
6
6
  import type { RunTerminal } from '../card/run-state.js';
7
- import type { AgentKind } from '../runner/types.js';
8
- import { QueueManager, type QueuedTask, type EnqueueOptions, type AgentBinding } from './queue-manager.js';
7
+ import { type AgentKind } from '../runner/types.js';
8
+ import { QueueManager, type QueuedTask, type EnqueueOptions, type AgentBinding, type ExecutingInfo } from './queue-manager.js';
9
9
  import type { InboundMediaPayload } from '../connector/index.js';
10
10
  import type { MediaOutcome } from '../inbound/turn.js';
11
11
  /**
@@ -141,10 +141,16 @@ export declare class Bridge {
141
141
  /** Approval coordinators keyed by runId. */
142
142
  private approvalCoordinators;
143
143
  /**
144
- * 最近一次已完成的、runner 有 runCompact 的 run(按 cwd)。run 结束后
145
- * activeRuns 已清空,Compact 需要它来校验 runId 并提供 sessionId。
144
+ * 最近若干次已完成的、runner 有 runCompact 的 run(cwd → runId → 记录)。
145
+ * run 结束后 activeRuns 已清空,而终态卡上的 Compact 按钮只带 runId,
146
+ * 需要它反查 sessionId + agentKind。
147
+ *
148
+ * 为什么按 runId 存而不是「每 cwd 只留最新一条」:compact 卡片动作走串行
149
+ * 队列(会被正在跑的 run 挡住),点击到执行之间可能夹着别的 run 收尾。
150
+ * 单槽会被覆盖 —— 旧卡片的压缩必然报「该任务已结束或不属于当前会话」,
151
+ * 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
146
152
  */
147
- private lastCompactableRun;
153
+ private compactableRuns;
148
154
  /**
149
155
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
150
156
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -233,6 +239,11 @@ export declare class Bridge {
233
239
  tasksAhead: number;
234
240
  isRunning: boolean;
235
241
  };
242
+ /**
243
+ * lane 当前占用者的展示信息(排队卡与「立即执行」守卫共用)。
244
+ * 典型用途:占用者是 Compact 时拒绝 queue.immediate,避免中断压缩。
245
+ */
246
+ getQueueExecutingInfo(cwd: string): ExecutingInfo | undefined;
236
247
  /** Remove a task from the queue by messageId. Returns true if found and removed. */
237
248
  removeFromQueue(cwd: string, messageId: string): boolean;
238
249
  /**
@@ -323,6 +334,17 @@ export declare class Bridge {
323
334
  * Returns true if the in-place update succeeded, false if it fell back.
324
335
  */
325
336
  updateCardInPlace(card: object, ctx: CommandContext): Promise<boolean>;
337
+ /**
338
+ * Best-effort in-place refresh of a KNOWN card (no fallback send).
339
+ *
340
+ * `updateCardInPlace` falls back to sending a new card when the PATCH fails,
341
+ * which is right for the card the user just clicked (a silent failure would
342
+ * leave the click with no feedback) but wrong when refreshing *sibling*
343
+ * cards: an expired/withdrawn card would spawn a duplicate card on every
344
+ * edit. Callers that fan out to several cards use this instead and drop the
345
+ * card id from their set on `false`.
346
+ */
347
+ patchCard(messageId: string, card: object): Promise<boolean>;
326
348
  /** Send a file to the user via Feishu. */
327
349
  sendFile(filePath: string, ctx: CommandContext): Promise<void>;
328
350
  /**
@@ -465,11 +487,27 @@ export declare class Bridge {
465
487
  text: string;
466
488
  nonce: string;
467
489
  }): Promise<void>;
490
+ /**
491
+ * 记下一次「可压缩 run」:终态卡上的 Compact 按钮点击时据此反查
492
+ * sessionId + agentKind。runId 是按钮自带的唯一键,同 cwd 内唯一。
493
+ * 超出上限按插入序淘汰最旧的一条(Map 保持插入序;重复写同一 runId
494
+ * 先 delete 再 set 以刷新位置)。
495
+ */
496
+ private recordCompactableRun;
497
+ /**
498
+ * 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
499
+ *
500
+ * 为什么不带 cwd 过滤:按钮属于「那一次 run」,与用户此刻在哪个工作目录、
501
+ * 排队期间有没有 /cd 无关。`/stop` 的 interruptCurrentRun 同样是全局按 runId
502
+ * 找(从不查当前 cwd),compact 与它保持一致。用当前 cwd 收窄会让「切了工作区
503
+ * 再点旧卡片」和「排队期间 /cd」两种情况误报「该任务已结束或不属于当前会话」。
504
+ */
505
+ private findCompactableRun;
468
506
  /**
469
507
  * Handle compact card action — trigger a compaction request for any
470
- * runCompact-capable runner(codex/kimi/opencode/pi/claude,按
471
- * lastCompactableRun 记录的 agentKind 路由)。Validates the run exists
472
- * and is in a terminal state, then calls the runner's runCompact().
508
+ * runCompact-capable runner(codex/kimi/opencode/pi/claude)。按钮带的是
509
+ * 那次 run 的 runId,据此反查该 run 实际使用的 sessionId + agentKind
510
+ * + cwd(compactableRuns,按 runId 记录),再调 runner 的 runCompact()。
473
511
  */
474
512
  handleCompactAction(value: {
475
513
  runId?: string;
@@ -11,6 +11,7 @@ import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
11
11
  import { enforceCardBudget } from '../card/card-budget.js';
12
12
  import { normalizeResultUsage } from '../runner/common/result-usage.js';
13
13
  import { BashProcessRunner } from '../runner/index.js';
14
+ import { AGENT_KINDS } from '../runner/types.js';
14
15
  import { QueueManager, } from './queue-manager.js';
15
16
  import { ApprovalCoordinator, decisionToApprovalAction } from './approval-coordinator.js';
16
17
  import { InboundMediaHandler } from './inbound-media.js';
@@ -56,6 +57,12 @@ function terminalReactionEmoji(terminal) {
56
57
  function runnerHasRunCompact(runner) {
57
58
  return ('runCompact' in runner && typeof runner.runCompact === 'function');
58
59
  }
60
+ /**
61
+ * 每个 cwd 保留的「可压缩 run」条数上限。Compact 按钮挂在终态 run 卡片上,
62
+ * 卡片会被后续消息不断顶上去;无上限会让长跑进程随 run 数无界增长,而用户
63
+ * 实际能点到的只有最近几张卡,20 条足以覆盖。
64
+ */
65
+ const MAX_COMPACTABLE_RUNS_PER_CWD = 20;
59
66
  /**
60
67
  * Bridge owns the per-workspace serial processing queue (§9.6) and the
61
68
  * single-card streaming forwarding loop (§9.12 watchdog + session sync).
@@ -112,10 +119,16 @@ export class Bridge {
112
119
  /** Approval coordinators keyed by runId. */
113
120
  approvalCoordinators = new Map();
114
121
  /**
115
- * 最近一次已完成的、runner 有 runCompact 的 run(按 cwd)。run 结束后
116
- * activeRuns 已清空,Compact 需要它来校验 runId 并提供 sessionId。
122
+ * 最近若干次已完成的、runner 有 runCompact 的 run(cwd → runId → 记录)。
123
+ * run 结束后 activeRuns 已清空,而终态卡上的 Compact 按钮只带 runId,
124
+ * 需要它反查 sessionId + agentKind。
125
+ *
126
+ * 为什么按 runId 存而不是「每 cwd 只留最新一条」:compact 卡片动作走串行
127
+ * 队列(会被正在跑的 run 挡住),点击到执行之间可能夹着别的 run 收尾。
128
+ * 单槽会被覆盖 —— 旧卡片的压缩必然报「该任务已结束或不属于当前会话」,
129
+ * 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
117
130
  */
118
- lastCompactableRun = new Map();
131
+ compactableRuns = new Map();
119
132
  /**
120
133
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
121
134
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -383,6 +396,13 @@ export class Bridge {
383
396
  getQueueInfo(cwd) {
384
397
  return this.queueManager.getQueueInfo(cwd);
385
398
  }
399
+ /**
400
+ * lane 当前占用者的展示信息(排队卡与「立即执行」守卫共用)。
401
+ * 典型用途:占用者是 Compact 时拒绝 queue.immediate,避免中断压缩。
402
+ */
403
+ getQueueExecutingInfo(cwd) {
404
+ return this.queueManager.getExecutingInfo(cwd);
405
+ }
386
406
  /** Remove a task from the queue by messageId. Returns true if found and removed. */
387
407
  removeFromQueue(cwd, messageId) {
388
408
  return this.queueManager.removeFromQueue(cwd, messageId);
@@ -663,6 +683,26 @@ export class Bridge {
663
683
  return false;
664
684
  }
665
685
  }
686
+ /**
687
+ * Best-effort in-place refresh of a KNOWN card (no fallback send).
688
+ *
689
+ * `updateCardInPlace` falls back to sending a new card when the PATCH fails,
690
+ * which is right for the card the user just clicked (a silent failure would
691
+ * leave the click with no feedback) but wrong when refreshing *sibling*
692
+ * cards: an expired/withdrawn card would spawn a duplicate card on every
693
+ * edit. Callers that fan out to several cards use this instead and drop the
694
+ * card id from their set on `false`.
695
+ */
696
+ async patchCard(messageId, card) {
697
+ try {
698
+ await this.connector.updateCard(messageId, card);
699
+ return true;
700
+ }
701
+ catch (err) {
702
+ getLogger().debug(`[lark-remote] patchCard failed messageId=${messageId}: ${errorMessage(err)}`);
703
+ return false;
704
+ }
705
+ }
666
706
  /** Send a file to the user via Feishu. */
667
707
  async sendFile(filePath, ctx) {
668
708
  try {
@@ -1335,11 +1375,7 @@ export class Bridge {
1335
1375
  const compactSessionId = cardSession.currentState.sessionId ??
1336
1376
  this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1337
1377
  if (compactSessionId) {
1338
- this.lastCompactableRun.set(cwd, {
1339
- runId: activeRun.runId,
1340
- sessionId: compactSessionId,
1341
- agentKind: activeRun.agentKind,
1342
- });
1378
+ this.recordCompactableRun(cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1343
1379
  }
1344
1380
  }
1345
1381
  getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${runId}`);
@@ -1481,11 +1517,48 @@ export class Bridge {
1481
1517
  log.info(`[lark-remote] handleApprovalAnswerNote runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
1482
1518
  return this.withCoordinator(opts.runId, (coordinator) => coordinator.answerNote({ questionIndex: opts.questionIndex, text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce }));
1483
1519
  }
1520
+ /**
1521
+ * 记下一次「可压缩 run」:终态卡上的 Compact 按钮点击时据此反查
1522
+ * sessionId + agentKind。runId 是按钮自带的唯一键,同 cwd 内唯一。
1523
+ * 超出上限按插入序淘汰最旧的一条(Map 保持插入序;重复写同一 runId
1524
+ * 先 delete 再 set 以刷新位置)。
1525
+ */
1526
+ recordCompactableRun(cwd, runId, sessionId, agentKind) {
1527
+ let byRunId = this.compactableRuns.get(cwd);
1528
+ if (!byRunId) {
1529
+ byRunId = new Map();
1530
+ this.compactableRuns.set(cwd, byRunId);
1531
+ }
1532
+ byRunId.delete(runId);
1533
+ byRunId.set(runId, { sessionId, agentKind, cwd });
1534
+ while (byRunId.size > MAX_COMPACTABLE_RUNS_PER_CWD) {
1535
+ const oldest = byRunId.keys().next().value;
1536
+ if (oldest === undefined)
1537
+ break;
1538
+ byRunId.delete(oldest);
1539
+ }
1540
+ }
1541
+ /**
1542
+ * 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
1543
+ *
1544
+ * 为什么不带 cwd 过滤:按钮属于「那一次 run」,与用户此刻在哪个工作目录、
1545
+ * 排队期间有没有 /cd 无关。`/stop` 的 interruptCurrentRun 同样是全局按 runId
1546
+ * 找(从不查当前 cwd),compact 与它保持一致。用当前 cwd 收窄会让「切了工作区
1547
+ * 再点旧卡片」和「排队期间 /cd」两种情况误报「该任务已结束或不属于当前会话」。
1548
+ */
1549
+ findCompactableRun(runId) {
1550
+ for (const byRunId of this.compactableRuns.values()) {
1551
+ const record = byRunId.get(runId);
1552
+ if (record)
1553
+ return record;
1554
+ }
1555
+ return undefined;
1556
+ }
1484
1557
  /**
1485
1558
  * Handle compact card action — trigger a compaction request for any
1486
- * runCompact-capable runner(codex/kimi/opencode/pi/claude,按
1487
- * lastCompactableRun 记录的 agentKind 路由)。Validates the run exists
1488
- * and is in a terminal state, then calls the runner's runCompact().
1559
+ * runCompact-capable runner(codex/kimi/opencode/pi/claude)。按钮带的是
1560
+ * 那次 run 的 runId,据此反查该 run 实际使用的 sessionId + agentKind
1561
+ * + cwd(compactableRuns,按 runId 记录),再调 runner 的 runCompact()。
1489
1562
  */
1490
1563
  async handleCompactAction(value, ctx) {
1491
1564
  const log = getLogger();
@@ -1495,36 +1568,32 @@ export class Bridge {
1495
1568
  await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
1496
1569
  return;
1497
1570
  }
1498
- const cwd = this.resolveCwd(ctx.userId);
1499
- if (!cwd) {
1500
- await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
1501
- return;
1502
- }
1503
- // 校验:run 结束后 activeRuns 已清空,用 lastCompactableRun 对照 runId。
1504
- const last = this.lastCompactableRun.get(cwd);
1505
- if (!last || last.runId !== runId) {
1571
+ // 校验:run 结束后 activeRuns 已清空,只能靠按钮自带的 runId 反查记录。
1572
+ // 按 runId 存(不按「最新的那一条」),点击到执行之间夹了新 run 收尾也不失效;
1573
+ // 查找也不按「当前 cwd」收窄(见 findCompactableRun)。
1574
+ const target = this.findCompactableRun(runId);
1575
+ if (!target) {
1506
1576
  await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
1507
1577
  return;
1508
1578
  }
1509
- if (!last.sessionId) {
1510
- await this.sendResult({ text: '⚠️ 未找到可压缩的会话' }, ctx);
1511
- return;
1512
- }
1579
+ // 压缩必须用那次 run 自己的 cwd(session 文件 + runner 实例都按 cwd 解析)。
1580
+ const cwd = target.cwd;
1513
1581
  // §5.3: kimi 压缩在途检测(wire 状态机)。第二次点击在本地毫秒级如实回答,
1514
1582
  // 不开卡、不发 session/prompt——否则 kimi 服务端拒绝并产生「秒回」假完成。
1515
- if (await this.rejectIfCompactionInFlight(last.sessionId, cwd, last.agentKind, ctx))
1583
+ if (await this.rejectIfCompactionInFlight(target.sessionId, cwd, target.agentKind, ctx)) {
1516
1584
  return;
1585
+ }
1517
1586
  // Check that the runner has runCompact method
1518
- const runner = this.getRunner(cwd, last.agentKind);
1587
+ const runner = this.getRunner(cwd, target.agentKind);
1519
1588
  if (!runnerHasRunCompact(runner)) {
1520
1589
  await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1521
1590
  return;
1522
1591
  }
1523
1592
  log.info(`[lark-remote] handleCompactAction executing runCompact for runId=${runId} cwd=${cwd}`);
1524
1593
  await this.streamCompact({
1525
- sessionId: last.sessionId,
1594
+ sessionId: target.sessionId,
1526
1595
  cwd,
1527
- agentKind: last.agentKind,
1596
+ agentKind: target.agentKind,
1528
1597
  runner,
1529
1598
  ctx,
1530
1599
  });
@@ -1731,9 +1800,10 @@ export class Bridge {
1731
1800
  return;
1732
1801
  }
1733
1802
  // Resolve agent kind the same way resume.use does: card value wins,
1734
- // otherwise fall back to defaultAgent.
1735
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
1736
- const agentKind = value.agent && validAgents.includes(value.agent)
1803
+ // otherwise fall back to defaultAgent. 名单来自单源 AGENT_KINDS —— 手抄一份
1804
+ // 必然再次漂移(这份曾经漏 dsh:带 dsh 的卡片会静默退回 defaultAgent,
1805
+ // 用错 reader 报「未找到 session」)。
1806
+ const agentKind = value.agent && AGENT_KINDS.includes(value.agent)
1737
1807
  ? value.agent
1738
1808
  : this.config.defaultAgent;
1739
1809
  // Session must exist in the current cwd (same rule as cmdResume /
@@ -1846,9 +1916,10 @@ export class Bridge {
1846
1916
  for await (const event of bashRunner.run(command, { cwd })) {
1847
1917
  if (event.type === 'stdout') {
1848
1918
  // 层④:bridge 本地 output 也必须 store-time 截断——仅 session state
1849
- // 有界并不会让 `output +=` 自然有界(review 第④条表述不成立),!yes 洪峰
1850
- // 下本地字符串仍全量驻留。capBashOutput 增量调用 O(CAP+chunk),不会带回
1851
- // O(n²) 截断成本(output 恒 ≤ CAP,拼接后 ≤ CAP+chunk 再 slice 尾部)。
1919
+ // 有界并不会让 `output +=` 自然有界(「store 有界则本地拼接自然有界」
1920
+ // 不成立),!yes 洪峰下本地字符串仍全量驻留。capBashOutput 增量调用
1921
+ // O(CAP+chunk),不会带回 O(n²) 截断成本(output 恒 ≤ CAP,拼接后
1922
+ // ≤ CAP+chunk 再 slice 尾部)。
1852
1923
  output = capBashOutput(output + event.content);
1853
1924
  await cardSession.update({ output, stderr });
1854
1925
  }
@@ -54,6 +54,29 @@ export interface QueuedTask {
54
54
  /** 入队时刻捕获的 agent+session 绑定,随任务闭包带到执行时刻。 */
55
55
  binding?: AgentBinding;
56
56
  }
57
+ /**
58
+ * lane 条目的展示类型。只影响排队卡如何描述「正在执行的是什么」:
59
+ * `message` 普通消息(缺省)、`compact` 压缩会话、`command` 其他卡片动作。
60
+ */
61
+ export type LaneTaskKind = 'message' | 'compact' | 'command';
62
+ /**
63
+ * lane 当前占用者的展示信息。由 begin 路径登记、settle 路径清除,与执行
64
+ * 槽位同寿命;排队卡靠它回答「我在等谁」。
65
+ */
66
+ export interface ExecutingInfo {
67
+ /**
68
+ * 归属标记:与执行槽位共用同一个 slot id。清除时校验它,防止一条迟到的
69
+ * settle 清掉后继任务的占用者(身份不用外部 id,同 §9.24 口径)。
70
+ */
71
+ slotId: number;
72
+ kind: LaneTaskKind;
73
+ /** 展示标签,例如 '💬 消息' / '🗜 Compact' / '📋 config.save'。 */
74
+ label: string;
75
+ /** 该条目自己的排队预览(可选)。 */
76
+ preview?: string;
77
+ /** 登记时刻(epoch ms)。当前只供诊断,卡片不渲染实时时长。 */
78
+ startedAt: number;
79
+ }
57
80
  /** Options for enqueue operation */
58
81
  export interface EnqueueOptions {
59
82
  taskMeta?: {
@@ -72,6 +95,12 @@ export interface EnqueueOptions {
72
95
  editable?: boolean;
73
96
  /** 入队时刻捕获的 agent+session 绑定。 */
74
97
  binding?: AgentBinding;
98
+ /**
99
+ * lane 占用者的展示类型,缺省 'message'。
100
+ * 'compact' 时排队卡写明「正在执行: 🗜 Compact」,并把「立即执行」置灰
101
+ * ——该按钮会中断压缩(见 buildQueueActionButtons)。
102
+ */
103
+ kind?: LaneTaskKind;
75
104
  };
76
105
  }
77
106
  /** Queue info for a cwd */
@@ -115,18 +144,23 @@ export declare class QueueManager {
115
144
  /**
116
145
  * Monotonic counter minting a unique slot id per enqueued task. A slot
117
146
  * identifies one task's execution period for the interrupt bookkeeping in
118
- * `executingSlot`/`interruptedSlots` — replacing the unowned skip-credit
147
+ * `executingInfo`/`interruptedSlots` — replacing the unowned skip-credit
119
148
  * counter, which any settle could consume and which therefore leaked a
120
149
  * re-armed count after repeated resets.
121
150
  */
122
151
  private slotCounter;
123
152
  /**
124
- * Per-workspace slot id of the task currently executing. Set in the begin
125
- * path right before the task runs; deleted when that task's settle fires.
126
- * `resetExecutingCount` reads it to bind the interrupt credit to exactly
127
- * the interrupted task's slot.
153
+ * Per-workspace record of the task currently executing — the lane occupant.
154
+ * Set in the begin path right before the task runs; deleted by that task's
155
+ * settle. Carries both the slot id (`resetExecutingCount` binds the
156
+ * interrupt credit to exactly the interrupted task's slot) and the display
157
+ * info the queue card needs to name what a waiting message waits for.
158
+ *
159
+ * 2026-10-09 线上:一次 Compact 占着 lane,新消息的排队卡却写「位置: 第 1
160
+ * 位 / 前面还有: 0 条消息」——开卡判据看 lane(含正在执行者),位置文案只看
161
+ * 等待队列,两个口径的差集正是这条记录。
128
162
  */
129
- private executingSlot;
163
+ private executingInfo;
130
164
  /**
131
165
  * Per-workspace set of slot ids whose task was interrupted by
132
166
  * `resetExecutingCount`. When such a slot settles, its decrement is skipped
@@ -184,6 +218,12 @@ export declare class QueueManager {
184
218
  * after it). Consumed when the task's slot begins; removed if cancelled.
185
219
  */
186
220
  setTaskReplacement(cwd: string, messageId: string, task: () => Promise<void>): void;
221
+ /**
222
+ * 排队卡头部。只有前面确实还有等待中的消息时才说「排队」;前面为空时
223
+ * 用户等的是当前正在执行的任务(例如 Compact),用「等待当前任务结束」
224
+ * 表达才不会与「前面还有 0 条」自相矛盾(2026-10-09 线上实例)。
225
+ */
226
+ private queueCardHeader;
187
227
  /** Send a queue status card showing current queue position and actions. */
188
228
  private sendQueueStatusCard;
189
229
  /** Get queue info for a cwd. */
@@ -199,6 +239,10 @@ export declare class QueueManager {
199
239
  * both. Centralizing the pair here eliminates the 3-way duplication
200
240
  * between `buildQueueStatusCardElements`, `updateQueueCardToExecuting`,
201
241
  * and `updateQueueCardToCancelled` (Clean Code).
242
+ *
243
+ * `opts.immediateLabel`(仅 pending 卡用)在 lane 被 Compact 占用时替换
244
+ * 「⚡ 立即执行」并强制置灰:点击它会中断正在跑的压缩。「❌ 撤销」不参与,
245
+ * 它只移除等待中的消息,不触碰 Compact。
202
246
  */
203
247
  private buildQueueActionButtons;
204
248
  /**
@@ -251,7 +295,7 @@ export declare class QueueManager {
251
295
  * id minted at enqueue time. Three cases:
252
296
  *
253
297
  * 1. The slot is the workspace's current executing slot → its execution
254
- * period ends (`executingSlot` marker removed).
298
+ * period ends (`executingInfo` record removed).
255
299
  * 2. The slot is in `interruptedSlots` (its task was interrupted by
256
300
  * `resetExecutingCount`) → skip the decrement: the reset already zeroed
257
301
  * the count, so a stale decrement could wrongly zero it again while a
@@ -279,7 +323,7 @@ export declare class QueueManager {
279
323
  * `expectedSlot` binds the reset to the task that was actually interrupted:
280
324
  * the caller captures the executing slot BEFORE stopping the runner, and
281
325
  * the stop window may outlive the interrupted task's settle (the chain can
282
- * advance to a NEW task, which then owns `executingSlot`). When the current
326
+ * advance to a NEW task, which then owns `executingInfo`). When the current
283
327
  * slot no longer matches the interrupted task's slot, the interrupted task
284
328
  * already decremented normally — resetting now would zero the count of the
285
329
  * running successor and mark ITS slot interrupted, hiding it from the
@@ -290,5 +334,12 @@ export declare class QueueManager {
290
334
  * The slot id of the task currently executing in this cwd (if any).
291
335
  */
292
336
  getExecutingSlot(cwd: string): number | undefined;
337
+ /**
338
+ * The lane occupant's display info for this cwd (if any). Queue cards read
339
+ * it to name what a waiting message is waiting for; `handleQueueImmediate`
340
+ * reads it to refuse interrupting a running Compact. Returns a copy so
341
+ * callers cannot mutate the live record.
342
+ */
343
+ getExecutingInfo(cwd: string): ExecutingInfo | undefined;
293
344
  }
294
345
  export {};