lark-remote 0.5.0 → 0.5.1
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 +16 -0
- package/dist/bridge/index.d.ts +46 -8
- package/dist/bridge/index.js +104 -33
- package/dist/bridge/queue-manager.d.ts +59 -8
- package/dist/bridge/queue-manager.js +120 -38
- package/dist/cli.js +2 -1
- package/dist/index.js +14 -3
- package/dist/router/index.d.ts +56 -7
- package/dist/router/index.js +107 -13
- package/dist/runner/codex/app-server/runner.d.ts +28 -0
- package/dist/runner/codex/app-server/runner.js +54 -1
- package/dist/runner/types.d.ts +10 -1
- package/dist/runner/types.js +12 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.1] - 2026-10-09
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- **旧卡片上的 Compact 按钮点下去不再报「该任务已结束或不属于当前会话」**:Compact 按钮只带本轮 run 的标识,此前按「每个工作目录最新一条 run」反查会话,而 compact 动作走串行队列——排队期间后续 run 收尾会覆盖那一个槽位,旧卡片于是必然失效。现在每条 run 收尾时按自己的标识记下会话与 agent,按钮自证身份,排队多久都不失效;Compact 也改为按该 run 自己的目录反查,中途切过目录不会压错会话
|
|
13
|
+
- **同一会话的多条排队卡片不再互相顶掉**:队列条目此前复用飞书卡片的消息 id 当身份,同一会话里多条卡片撞同一个 key,先入队的那条被后面的覆盖、状态更新串到别的卡片上。改为每次入队铸造独立身份
|
|
14
|
+
- **排队卡片会说清「在等谁」**:Compact 与普通消息共用同一条串行队列,Compact 占着执行位时新消息卡片仍写「位置: 第 1 位 / 前面还有: 0 条」,看着像马上就能轮到自己。现在没有等待中的消息时头部改为「等待当前任务结束」、正文写明正在执行的是 Compact 并收起位置行;占用者是 Compact 时「⚡ 立即执行」在渲染层置灰、执行层再加一道守卫,「❌ 撤销」保持可用
|
|
15
|
+
- **codex 多 agent 会话不再把会话指针写坏**:codex app-server 会把子 agent 的开轮通知一起推过来,此前不判断归属,子线程的事件覆盖了本轮的线程标识,顶层会话指针被写成子线程标识——该值在会话索引里不是键,之后恢复会话报 `cannot resume an unloaded multi-agent v2 sub-agent`,`/restart` 也救不回来。现在通知按线程归属过滤;审批结算仍按请求标识走,子 agent 的审批照常可以回答
|
|
16
|
+
- **带 `dsh` 的卡片不再静默退回默认 agent**:agent 名单此前被手抄成多份并发生漂移,收敛为单一来源、校验由该名单派生,消除「用错 reader → 未找到 session」的静默降级
|
|
17
|
+
|
|
18
|
+
### 变更
|
|
19
|
+
|
|
20
|
+
- **消除两处宿主环境相关的测试假红**:宿主带上本机没有的 locale(macOS 常见的 `LC_ALL=C.UTF-8`)时,bash 每次启动都往 stderr 打 setlocale 警告,让输出断言随机变红,测试 setup 阶段现在剔掉该变量(保留 UTF-8 解析所需项);JSON-RPC 退出计时用例的计时起点从进程启动改到收到 `initialize` 应答,慢速 Windows runner 上不再因启动耗时超过阈值而误红
|
|
21
|
+
- **英文架构文档补齐**:`docs/en/architecture/design.md` 增补与中文侧对应的五个章节,英中两侧不再不对称
|
|
22
|
+
- **注释与用例名清理**:延续上一版的清理,去掉剩余指向未发布文档的引用与评审编号,并修正清理过程中留下的残句。无行为变化
|
|
23
|
+
|
|
8
24
|
## [0.5.0] - 2026-10-07
|
|
9
25
|
|
|
10
26
|
### 新增
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
145
|
-
* activeRuns
|
|
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
|
|
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
|
-
*
|
|
472
|
-
*
|
|
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;
|
package/dist/bridge/index.js
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
* activeRuns
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
1488
|
-
*
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
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(
|
|
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,
|
|
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:
|
|
1594
|
+
sessionId: target.sessionId,
|
|
1526
1595
|
cwd,
|
|
1527
|
-
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
|
-
|
|
1736
|
-
|
|
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 +=`
|
|
1850
|
-
//
|
|
1851
|
-
// O(n²) 截断成本(output 恒 ≤ CAP,拼接后
|
|
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
|
-
* `
|
|
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
|
|
125
|
-
* path right before the task runs; deleted
|
|
126
|
-
*
|
|
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
|
|
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 (`
|
|
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 `
|
|
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 {};
|
|
@@ -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
|
-
* `
|
|
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
|
|
56
|
-
* path right before the task runs; deleted
|
|
57
|
-
*
|
|
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
|
-
|
|
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
|
|
285
|
-
// it. `resetExecutingCount` reads
|
|
286
|
-
// credit to exactly this task; the settle removes the
|
|
287
|
-
|
|
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,50 @@ 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
|
|
426
|
+
const occupant = this.executingInfo.get(cwd);
|
|
383
427
|
const elements = [
|
|
384
428
|
{
|
|
429
|
+
tag: 'div',
|
|
430
|
+
text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
// 「队列位置 / 前面还有」只在前面确实还有等待中的消息时渲染。前面为空时
|
|
434
|
+
// 这两个数是「第 1 位 / 0 条」,与「排队中」字面矛盾;此时挡住用户的只有
|
|
435
|
+
// 正在执行的任务,交给下一段如实说明。
|
|
436
|
+
if (tasksAhead > 0) {
|
|
437
|
+
elements.push({
|
|
385
438
|
tag: 'div',
|
|
386
439
|
text: {
|
|
387
440
|
tag: 'lark_md',
|
|
388
|
-
content:
|
|
441
|
+
content: `**队列位置:** 第 ${actualPosition} 位\n**前面还有:** ${tasksAhead} 条消息`,
|
|
389
442
|
},
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (occupant) {
|
|
446
|
+
elements.push({
|
|
447
|
+
tag: 'div',
|
|
448
|
+
text: { tag: 'lark_md', content: `**正在执行:** ${occupant.label}` },
|
|
449
|
+
});
|
|
450
|
+
elements.push({
|
|
393
451
|
tag: 'div',
|
|
394
452
|
text: {
|
|
395
453
|
tag: 'lark_md',
|
|
396
|
-
content:
|
|
454
|
+
content: occupant.kind === 'compact'
|
|
455
|
+
? '💡 压缩结束后将按顺序执行你的消息'
|
|
456
|
+
: '💡 消息将按顺序执行…',
|
|
397
457
|
},
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
// 防御分支:开卡判据是 lane 非空,所以「有排队卡但没有占用者」说明计数
|
|
462
|
+
// 与占用者记录漂移了。不改行为,只留证据(与 duplicate queue key 同口径)。
|
|
463
|
+
getLogger().warn(`[queue-manager] queue card without lane occupant cwd=${cwd} tasksAhead=${tasksAhead}`);
|
|
464
|
+
elements.push({
|
|
465
|
+
tag: 'div',
|
|
466
|
+
text: { tag: 'lark_md', content: '💡 消息将按顺序执行…' },
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
elements.push({ tag: 'hr' });
|
|
401
470
|
// Show message preview with edit button (only if not executing AND the
|
|
402
471
|
// task is editable). One-shot card actions like Compact enqueue with
|
|
403
472
|
// editable=false: their preview is not user-editable text, so the ✏️ 编辑
|
|
@@ -423,8 +492,10 @@ export class QueueManager {
|
|
|
423
492
|
}
|
|
424
493
|
elements.push({ tag: 'hr' });
|
|
425
494
|
}
|
|
426
|
-
// Action buttons
|
|
427
|
-
|
|
495
|
+
// Action buttons. Compact 占着 lane 时「⚡ 立即执行」会中断压缩
|
|
496
|
+
// (interruptCurrentRun 遍历 activeRuns,压缩也注册在里面),按钮置灰并
|
|
497
|
+
// 换文案说明;执行层 handleQueueImmediate 再做一次同样的守卫兜住旧卡片。
|
|
498
|
+
elements.push(...this.buildQueueActionButtons(cwd, messageId, false, occupant?.kind === 'compact' ? { immediateLabel: '压缩完成后按顺序执行' } : undefined));
|
|
428
499
|
return elements;
|
|
429
500
|
}
|
|
430
501
|
/**
|
|
@@ -436,8 +507,12 @@ export class QueueManager {
|
|
|
436
507
|
* both. Centralizing the pair here eliminates the 3-way duplication
|
|
437
508
|
* between `buildQueueStatusCardElements`, `updateQueueCardToExecuting`,
|
|
438
509
|
* and `updateQueueCardToCancelled` (Clean Code).
|
|
510
|
+
*
|
|
511
|
+
* `opts.immediateLabel`(仅 pending 卡用)在 lane 被 Compact 占用时替换
|
|
512
|
+
* 「⚡ 立即执行」并强制置灰:点击它会中断正在跑的压缩。「❌ 撤销」不参与,
|
|
513
|
+
* 它只移除等待中的消息,不触碰 Compact。
|
|
439
514
|
*/
|
|
440
|
-
buildQueueActionButtons(cwd, messageId, disabled) {
|
|
515
|
+
buildQueueActionButtons(cwd, messageId, disabled, opts) {
|
|
441
516
|
return [
|
|
442
517
|
{
|
|
443
518
|
tag: 'button',
|
|
@@ -448,9 +523,9 @@ export class QueueManager {
|
|
|
448
523
|
},
|
|
449
524
|
{
|
|
450
525
|
tag: 'button',
|
|
451
|
-
text: { tag: 'plain_text', content: '⚡ 立即执行' },
|
|
526
|
+
text: { tag: 'plain_text', content: opts?.immediateLabel ?? '⚡ 立即执行' },
|
|
452
527
|
type: 'primary',
|
|
453
|
-
disabled,
|
|
528
|
+
disabled: disabled || opts?.immediateLabel !== undefined,
|
|
454
529
|
behaviors: [{ type: 'callback', value: { cmd: 'queue.immediate', cwd, messageId } }],
|
|
455
530
|
},
|
|
456
531
|
];
|
|
@@ -633,10 +708,7 @@ export class QueueManager {
|
|
|
633
708
|
return {
|
|
634
709
|
schema: '2.0',
|
|
635
710
|
config: { wide_screen_mode: true },
|
|
636
|
-
header:
|
|
637
|
-
template: 'orange',
|
|
638
|
-
title: { tag: 'plain_text', content: '⏳ 消息排队中' },
|
|
639
|
-
},
|
|
711
|
+
header: this.queueCardHeader(tasksAhead),
|
|
640
712
|
body: {
|
|
641
713
|
elements: this.buildQueueStatusCardElements(cwd, actualPosition, tasksAhead, messageId, newMessagePreview),
|
|
642
714
|
},
|
|
@@ -650,7 +722,7 @@ export class QueueManager {
|
|
|
650
722
|
* id minted at enqueue time. Three cases:
|
|
651
723
|
*
|
|
652
724
|
* 1. The slot is the workspace's current executing slot → its execution
|
|
653
|
-
* period ends (`
|
|
725
|
+
* period ends (`executingInfo` record removed).
|
|
654
726
|
* 2. The slot is in `interruptedSlots` (its task was interrupted by
|
|
655
727
|
* `resetExecutingCount`) → skip the decrement: the reset already zeroed
|
|
656
728
|
* the count, so a stale decrement could wrongly zero it again while a
|
|
@@ -658,8 +730,8 @@ export class QueueManager {
|
|
|
658
730
|
* 3. Otherwise → normal decrement.
|
|
659
731
|
*/
|
|
660
732
|
decrementExecutingCount(cwd, slotId) {
|
|
661
|
-
if (this.
|
|
662
|
-
this.
|
|
733
|
+
if (this.executingInfo.get(cwd)?.slotId === slotId) {
|
|
734
|
+
this.executingInfo.delete(cwd);
|
|
663
735
|
}
|
|
664
736
|
const interrupted = this.interruptedSlots.get(cwd);
|
|
665
737
|
if (interrupted?.has(slotId)) {
|
|
@@ -693,14 +765,14 @@ export class QueueManager {
|
|
|
693
765
|
* `expectedSlot` binds the reset to the task that was actually interrupted:
|
|
694
766
|
* the caller captures the executing slot BEFORE stopping the runner, and
|
|
695
767
|
* the stop window may outlive the interrupted task's settle (the chain can
|
|
696
|
-
* advance to a NEW task, which then owns `
|
|
768
|
+
* advance to a NEW task, which then owns `executingInfo`). When the current
|
|
697
769
|
* slot no longer matches the interrupted task's slot, the interrupted task
|
|
698
770
|
* already decremented normally — resetting now would zero the count of the
|
|
699
771
|
* running successor and mark ITS slot interrupted, hiding it from the
|
|
700
772
|
* queue card.
|
|
701
773
|
*/
|
|
702
774
|
resetExecutingCount(cwd, expectedSlot) {
|
|
703
|
-
const currentSlot = this.
|
|
775
|
+
const currentSlot = this.executingInfo.get(cwd)?.slotId;
|
|
704
776
|
if (currentSlot !== expectedSlot) {
|
|
705
777
|
getLogger().debug(`[queue-manager] resetExecutingCount skip (stopped task settled, slot advanced) cwd=${cwd} expectedSlot=${expectedSlot} currentSlot=${currentSlot ?? 'none'}`);
|
|
706
778
|
return;
|
|
@@ -733,7 +805,17 @@ export class QueueManager {
|
|
|
733
805
|
* The slot id of the task currently executing in this cwd (if any).
|
|
734
806
|
*/
|
|
735
807
|
getExecutingSlot(cwd) {
|
|
736
|
-
return this.
|
|
808
|
+
return this.executingInfo.get(cwd)?.slotId;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* The lane occupant's display info for this cwd (if any). Queue cards read
|
|
812
|
+
* it to name what a waiting message is waiting for; `handleQueueImmediate`
|
|
813
|
+
* reads it to refuse interrupting a running Compact. Returns a copy so
|
|
814
|
+
* callers cannot mutate the live record.
|
|
815
|
+
*/
|
|
816
|
+
getExecutingInfo(cwd) {
|
|
817
|
+
const info = this.executingInfo.get(cwd);
|
|
818
|
+
return info ? { ...info } : undefined;
|
|
737
819
|
}
|
|
738
820
|
}
|
|
739
821
|
//# 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
|
-
*
|
|
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
|
-
|
|
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:
|
|
646
|
+
editable: !COMPACT_ACTION_CMDS.has(actionValue.cmd),
|
|
636
647
|
},
|
|
637
648
|
});
|
|
638
649
|
}
|
package/dist/router/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
|
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
|
*
|
package/dist/router/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
363
|
-
|
|
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
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
415
|
+
* 已见过的 /config 卡片 messageId(有界,最近的在最后)。
|
|
416
|
+
*
|
|
417
|
+
* 草稿(pendingConfig)是全局一份:/config 文本命令重开卡片时沿用同一份草稿
|
|
418
|
+
* (这是既有语义,不改)。但**视图**是每张卡一份 —— 只刷新被点击的那张,其余
|
|
419
|
+
* 卡片就停在旧视图上:用户在旧卡上看到的和点保存实际提交的是两套内容
|
|
420
|
+
* (2026-10-08 发现的缺陷)。这里记住所有交互过的 config 卡,任何一次编辑/保存
|
|
421
|
+
* 后把草稿刷到每张卡,保证「看到的就是会保存的」。
|
|
402
422
|
*/
|
|
403
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1970
|
+
await this.refreshConfigCards(ctx);
|
|
1877
1971
|
}
|
|
1878
1972
|
catch (refreshErr) {
|
|
1879
1973
|
getLogger().warn(`[router] config.save 卡片刷新失败(保存已成功): ${refreshErr.message}`);
|
|
@@ -42,6 +42,8 @@ export declare class CodexAppServerRunner extends ConnectionBasedRunner<JsonRpcC
|
|
|
42
42
|
private connectionManager;
|
|
43
43
|
private currentTranslator;
|
|
44
44
|
private activeThreadId;
|
|
45
|
+
/** 本轮已记录过日志的外来(子 agent)线程 id,避免逐条 delta 刷盘。 */
|
|
46
|
+
private foreignThreadIds;
|
|
45
47
|
private model?;
|
|
46
48
|
private modelProvider?;
|
|
47
49
|
private reasoningEffort?;
|
|
@@ -91,7 +93,33 @@ export declare class CodexAppServerRunner extends ConnectionBasedRunner<JsonRpcC
|
|
|
91
93
|
* thread/resume(按 sessionId 恢复既有线程)→ turn/start。
|
|
92
94
|
*/
|
|
93
95
|
protected setupTurn(message: string, opts: SpawnOptions): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Route one server notification into the current turn's translator.
|
|
98
|
+
*
|
|
99
|
+
* App-server 把已知线程的通知全部复用同一条 stdio 连接:agent 用
|
|
100
|
+
* multi-agent v2 派生子 agent 后,子线程的 `turn/started`、`item/*`、
|
|
101
|
+
* `turn/completed`、`thread/tokenUsage/updated` 会与本轮通知交错到达
|
|
102
|
+
* (2026-10-09 实测)。一个 turn 只属于一个线程,因此外来线程的通知一律
|
|
103
|
+
* 丢弃——否则子线程的 `turn/started` 会覆盖本轮 threadId,会话写回把子
|
|
104
|
+
* 线程 id 当成顶层会话指针存盘(冷连接 `thread/resume` 直接报
|
|
105
|
+
* "cannot resume an unloaded multi-agent v2 sub-agent"),且子线程的
|
|
106
|
+
* `turn/completed` 会提前结束本轮 run、token 用量也会串台。
|
|
107
|
+
*/
|
|
94
108
|
private handleNotification;
|
|
109
|
+
/**
|
|
110
|
+
* 线程归属闸门:通知是否属于本轮 turn 的线程。
|
|
111
|
+
*
|
|
112
|
+
* - 无 threadId 的通知(如部分 warning)没有线程维度可用,放行;
|
|
113
|
+
* - `serverRequest/resolved` 保持线程无关:审批按 requestId 关联(桥侧
|
|
114
|
+
* ApprovalCoordinator 跟踪),子 agent 的审批同样必须能结算,否则卡片
|
|
115
|
+
* 上的审批条目会一直挂在 pending。
|
|
116
|
+
*/
|
|
117
|
+
private belongsToActiveThread;
|
|
118
|
+
/**
|
|
119
|
+
* 每个被忽略的外来线程只记一条日志:这是会话指针被污染的入口,出问题时
|
|
120
|
+
* 必须能从日志直接看到「本轮忽略了哪些子线程」,同时避免逐条 delta 刷盘。
|
|
121
|
+
*/
|
|
122
|
+
private noteForeignThread;
|
|
95
123
|
private handleServerRequest;
|
|
96
124
|
private buildGrantedPermissions;
|
|
97
125
|
/** 拒绝权限审批:空 fileSystem + 关闭网络(真实 GrantedPermissionProfile)。 */
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { ConnectionManager, } from '../../common/jsonrpc/connection-manager.js';
|
|
14
14
|
import { CodexAppServerTranslator } from './translator.js';
|
|
15
|
+
import { NotificationMethod, } from './protocol-types.js';
|
|
15
16
|
import { RpcErrorCode } from '../../common/acp/protocol-types.js';
|
|
16
17
|
import { mapAnswersByIndex } from '../../question-common.js';
|
|
17
18
|
import { getLogger } from '../../../logger/index.js';
|
|
@@ -102,6 +103,8 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
|
|
|
102
103
|
connectionManager;
|
|
103
104
|
currentTranslator = null;
|
|
104
105
|
activeThreadId = null;
|
|
106
|
+
/** 本轮已记录过日志的外来(子 agent)线程 id,避免逐条 delta 刷盘。 */
|
|
107
|
+
foreignThreadIds = new Set();
|
|
105
108
|
model;
|
|
106
109
|
modelProvider;
|
|
107
110
|
reasoningEffort;
|
|
@@ -171,6 +174,7 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
|
|
|
171
174
|
clearTurnState() {
|
|
172
175
|
this.currentTranslator = null;
|
|
173
176
|
this.activeThreadId = null;
|
|
177
|
+
this.foreignThreadIds.clear();
|
|
174
178
|
}
|
|
175
179
|
async releaseConnection(cwd) {
|
|
176
180
|
await this.connectionManager.release(cwd);
|
|
@@ -362,7 +366,8 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
|
|
|
362
366
|
// 故 thread.id 可同时用作协议 threadId 与 store/session reader 的 session
|
|
363
367
|
// 键(bridge 用 turn_started 通知的 threadId 写回,/resume、Compact 按此
|
|
364
368
|
// 键定位 JSONL)。forked/subagent 线程二者会分叉(openai/codex#29327),
|
|
365
|
-
//
|
|
369
|
+
// 桥只把主线程作为顶层会话——子线程的通知由 handleNotification 的线程
|
|
370
|
+
// 归属闸门挡掉,不会进来(见 belongsToActiveThread)。
|
|
366
371
|
threadId = threadResult.thread.id;
|
|
367
372
|
}
|
|
368
373
|
this.activeThreadId = threadId;
|
|
@@ -372,10 +377,58 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
|
|
|
372
377
|
const turnResult = await client.request('turn/start', turnParams);
|
|
373
378
|
this.currentTurnId = turnResult.turn.id;
|
|
374
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Route one server notification into the current turn's translator.
|
|
382
|
+
*
|
|
383
|
+
* App-server 把已知线程的通知全部复用同一条 stdio 连接:agent 用
|
|
384
|
+
* multi-agent v2 派生子 agent 后,子线程的 `turn/started`、`item/*`、
|
|
385
|
+
* `turn/completed`、`thread/tokenUsage/updated` 会与本轮通知交错到达
|
|
386
|
+
* (2026-10-09 实测)。一个 turn 只属于一个线程,因此外来线程的通知一律
|
|
387
|
+
* 丢弃——否则子线程的 `turn/started` 会覆盖本轮 threadId,会话写回把子
|
|
388
|
+
* 线程 id 当成顶层会话指针存盘(冷连接 `thread/resume` 直接报
|
|
389
|
+
* "cannot resume an unloaded multi-agent v2 sub-agent"),且子线程的
|
|
390
|
+
* `turn/completed` 会提前结束本轮 run、token 用量也会串台。
|
|
391
|
+
*/
|
|
375
392
|
handleNotification(method, params) {
|
|
393
|
+
if (!this.belongsToActiveThread(method, params)) {
|
|
394
|
+
this.noteForeignThread(params);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
376
397
|
const events = this.currentTranslator?.handleNotification(method, params) ?? [];
|
|
377
398
|
this.pushEvents(events);
|
|
378
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* 线程归属闸门:通知是否属于本轮 turn 的线程。
|
|
402
|
+
*
|
|
403
|
+
* - 无 threadId 的通知(如部分 warning)没有线程维度可用,放行;
|
|
404
|
+
* - `serverRequest/resolved` 保持线程无关:审批按 requestId 关联(桥侧
|
|
405
|
+
* ApprovalCoordinator 跟踪),子 agent 的审批同样必须能结算,否则卡片
|
|
406
|
+
* 上的审批条目会一直挂在 pending。
|
|
407
|
+
*/
|
|
408
|
+
belongsToActiveThread(method, params) {
|
|
409
|
+
const active = this.activeThreadId;
|
|
410
|
+
if (!active)
|
|
411
|
+
return true;
|
|
412
|
+
if (method === NotificationMethod.SERVER_REQUEST_RESOLVED)
|
|
413
|
+
return true;
|
|
414
|
+
const threadId = params?.threadId;
|
|
415
|
+
if (typeof threadId !== 'string' || threadId.length === 0)
|
|
416
|
+
return true;
|
|
417
|
+
return threadId === active;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 每个被忽略的外来线程只记一条日志:这是会话指针被污染的入口,出问题时
|
|
421
|
+
* 必须能从日志直接看到「本轮忽略了哪些子线程」,同时避免逐条 delta 刷盘。
|
|
422
|
+
*/
|
|
423
|
+
noteForeignThread(params) {
|
|
424
|
+
const threadId = params?.threadId;
|
|
425
|
+
if (typeof threadId !== 'string' || threadId.length === 0)
|
|
426
|
+
return;
|
|
427
|
+
if (this.foreignThreadIds.has(threadId))
|
|
428
|
+
return;
|
|
429
|
+
this.foreignThreadIds.add(threadId);
|
|
430
|
+
getLogger().info(`[${this.logTag}] ignoring notifications from foreign thread threadId=${threadId} activeThreadId=${this.activeThreadId}`);
|
|
431
|
+
}
|
|
379
432
|
handleServerRequest(id, method, params) {
|
|
380
433
|
const events = this.currentTranslator?.handleServerRequest(id, method, params) ?? [];
|
|
381
434
|
if (events.length === 0) {
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -402,7 +402,16 @@ export interface Runner {
|
|
|
402
402
|
*/
|
|
403
403
|
unregisterExitHandlers?(): void;
|
|
404
404
|
}
|
|
405
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Agent 名单单源(运行时数组 + 类型同源,二者不可能漂移)。
|
|
407
|
+
*
|
|
408
|
+
* 历史上这份名单被手抄成多份(router 的 VALID_AGENTS、bridge 的 resume.compact
|
|
409
|
+
* 校验、各 agent 的 config builder…),漂移过:dsh 加入后 bridge 的
|
|
410
|
+
* resume.compact 仍只有 5 个,带 dsh 的 resume 卡片会静默退回 defaultAgent。
|
|
411
|
+
* 新增 agent 只改这里。
|
|
412
|
+
*/
|
|
413
|
+
export declare const AGENT_KINDS: readonly ["claude", "codex", "opencode", "pi", "kimi", "dsh"];
|
|
414
|
+
export type AgentKind = (typeof AGENT_KINDS)[number];
|
|
406
415
|
/** Unified session descriptor returned by every AgentSessionReader. */
|
|
407
416
|
export interface AgentSession {
|
|
408
417
|
sessionId: string;
|
package/dist/runner/types.js
CHANGED
|
@@ -4,5 +4,16 @@
|
|
|
4
4
|
* 本文件包含所有 Agent 相关的类型定义,从 runner/index.ts 提取。
|
|
5
5
|
* 所有下游模块应从本文件 import 类型,而非直接依赖实现。
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Multi-Agent Adapter Types
|
|
9
|
+
// =============================================================================
|
|
10
|
+
/**
|
|
11
|
+
* Agent 名单单源(运行时数组 + 类型同源,二者不可能漂移)。
|
|
12
|
+
*
|
|
13
|
+
* 历史上这份名单被手抄成多份(router 的 VALID_AGENTS、bridge 的 resume.compact
|
|
14
|
+
* 校验、各 agent 的 config builder…),漂移过:dsh 加入后 bridge 的
|
|
15
|
+
* resume.compact 仍只有 5 个,带 dsh 的 resume 卡片会静默退回 defaultAgent。
|
|
16
|
+
* 新增 agent 只改这里。
|
|
17
|
+
*/
|
|
18
|
+
export const AGENT_KINDS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
|
|
8
19
|
//# sourceMappingURL=types.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-remote",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|