wezard 2.7.3 → 2.7.7
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/dist/daemon/handoff.js +49 -35
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/inbound.js +1 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +469 -245
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +7 -4
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/message-events.js +197 -29
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +477 -147
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +11 -6
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/router.js +93 -28
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/task-registry.js +47 -21
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +68 -26
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tmux-buffer.js +1 -1
- package/dist/daemon/tmux-buffer.js.map +1 -1
- package/dist/daemon/wizard-names.js +63 -0
- package/dist/daemon/wizard-names.js.map +1 -0
- package/dist/daemon/wizard.js +16 -5
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +8 -16
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/task-file.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { finalId } from "./message-events.js";
|
|
1
|
+
import { finalId, receiptCoverage, receiptQuiet } from "./message-events.js";
|
|
2
2
|
import { parseEnvelope, renderTail } from "../shared/reminder.js";
|
|
3
|
-
import { codexSessions, codexSessionForPane } from "../shared/codex.js";
|
|
3
|
+
import { codexSessions, codexSessionForPane, recordCodexPaneSession } from "../shared/codex.js";
|
|
4
4
|
import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
// Mirror-mode bridge.
|
|
@@ -48,7 +48,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, windowLa
|
|
|
48
48
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
49
49
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
50
50
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
51
|
-
import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat } from "./peers.js";
|
|
51
|
+
import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat, parseClosing } from "./peers.js";
|
|
52
52
|
// PATH augmentation: launchd / systemd start the daemon
|
|
53
53
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
54
54
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
@@ -1305,6 +1305,9 @@ export const parseInputBox = (cap, styledLayout = cap) => {
|
|
|
1305
1305
|
// Idle Codex hides the left hint after a paste but keeps a right-aligned
|
|
1306
1306
|
// warnings footer. Its middle dot is not the status row's separator.
|
|
1307
1307
|
const warnings = /^\s*⚠\s*\d+\s+warnings?\s*·\s*f2 to view\s*$/u.test(layout[footer] ?? "");
|
|
1308
|
+
// --no-alt-screen 的 composer 没有背景 SGR; 原生 Context + cwd 行才是状态锚, warning 的 · 不能充当它。
|
|
1309
|
+
const inlineStatus = /\bContext\s+\d+%\s+used\b/u.test(layout[footer] ?? "")
|
|
1310
|
+
&& /·\s+(?:\/|~\/)/u.test(layout[footer] ?? "");
|
|
1308
1311
|
if (inputHint || warnings || (layout[footer] ?? "").includes("·")) {
|
|
1309
1312
|
let status = inputHint || warnings ? footer - 1 : footer;
|
|
1310
1313
|
while (status >= 0 && !layout[status].trim())
|
|
@@ -1318,7 +1321,7 @@ export const parseInputBox = (cap, styledLayout = cap) => {
|
|
|
1318
1321
|
prompt--;
|
|
1319
1322
|
if (prompt >= 0 && /^›(?:\s|$)/u.test(layout[prompt])
|
|
1320
1323
|
&& (prompt === 0 || !layout[prompt - 1].trim())
|
|
1321
|
-
&& (inputHint || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
|
|
1324
|
+
&& (inputHint || inlineStatus || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
|
|
1322
1325
|
const body = dropDim(styledLines.slice(prompt, status).join("\n"))
|
|
1323
1326
|
.replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
|
|
1324
1327
|
return { known: true, body };
|
|
@@ -1555,7 +1558,7 @@ const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
|
|
|
1555
1558
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1556
1559
|
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1557
1560
|
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1558
|
-
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId) => {
|
|
1561
|
+
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId, physicalGuard) => {
|
|
1559
1562
|
const backendName = backend.name;
|
|
1560
1563
|
log.info({ target, eventId, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1561
1564
|
// 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
|
|
@@ -1663,7 +1666,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1663
1666
|
// dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
|
|
1664
1667
|
// withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
|
|
1665
1668
|
if (backendName === "codebuddy") {
|
|
1666
|
-
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard);
|
|
1669
|
+
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard, physicalGuard);
|
|
1667
1670
|
}
|
|
1668
1671
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1669
1672
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
@@ -1715,7 +1718,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1715
1718
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1716
1719
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1717
1720
|
}
|
|
1718
|
-
return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard);
|
|
1721
|
+
return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard, physicalGuard);
|
|
1719
1722
|
};
|
|
1720
1723
|
// 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
|
|
1721
1724
|
// 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
|
|
@@ -1737,7 +1740,7 @@ const withImageRefs = (text, images) => {
|
|
|
1737
1740
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1738
1741
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1739
1742
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1740
|
-
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId, bypassModalGuard = false) => {
|
|
1743
|
+
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId, bypassModalGuard = false, physicalGuard) => {
|
|
1741
1744
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1742
1745
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1743
1746
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1793,7 +1796,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1793
1796
|
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
|
|
1794
1797
|
const loadAndPaste = async () => {
|
|
1795
1798
|
const start = Date.now();
|
|
1796
|
-
const result = await pasteTmuxBuffer(target, text,
|
|
1799
|
+
const result = await pasteTmuxBuffer(target, text, async () => (!physicalGuard || await physicalGuard("paste")) && (!guard || guard("paste")));
|
|
1797
1800
|
log.info({ target, eventId, tmuxPasteMs: Date.now() - start, boxVerify, ok: result.ok }, "mirror inject: paste completed");
|
|
1798
1801
|
return result;
|
|
1799
1802
|
};
|
|
@@ -1844,6 +1847,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1844
1847
|
let pastedAt = Date.now();
|
|
1845
1848
|
if (alreadyTyped) {
|
|
1846
1849
|
// Existing text is already exposed, even if permission was just withdrawn.
|
|
1850
|
+
if (physicalGuard && !(await physicalGuard("submit")))
|
|
1851
|
+
return { ok: false, uncertain: true, reason: "original handoff CLI identity changed before existing input submission" };
|
|
1847
1852
|
if (guard && !guard("paste"))
|
|
1848
1853
|
return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
|
|
1849
1854
|
log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
|
|
@@ -1939,6 +1944,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1939
1944
|
if (!box.known || box.body !== landedBody)
|
|
1940
1945
|
return { ok: false, reason: "composer changed after paste verification; refusing Enter" };
|
|
1941
1946
|
}
|
|
1947
|
+
if (physicalGuard && !(await physicalGuard("submit")))
|
|
1948
|
+
return { ok: false, reason: "original handoff CLI identity changed before submit" };
|
|
1942
1949
|
if (guard && !guard("submit"))
|
|
1943
1950
|
return { ok: false, reason: "submit canceled by dispatch guard" };
|
|
1944
1951
|
if (tailFp && !Number.isFinite(enterAt)) {
|
|
@@ -2171,7 +2178,7 @@ const inject = async (args) => {
|
|
|
2171
2178
|
}
|
|
2172
2179
|
return true;
|
|
2173
2180
|
};
|
|
2174
|
-
return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId);
|
|
2181
|
+
return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId, args.physicalGuard);
|
|
2175
2182
|
}, { guard: args.guard }));
|
|
2176
2183
|
}
|
|
2177
2184
|
catch (error) {
|
|
@@ -2365,7 +2372,98 @@ export const startMirror = (deps) => {
|
|
|
2365
2372
|
};
|
|
2366
2373
|
const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
|
|
2367
2374
|
let eventFinalGate = () => true;
|
|
2368
|
-
let
|
|
2375
|
+
let eventProgressGate = () => false;
|
|
2376
|
+
let eventOwnerGate = () => true;
|
|
2377
|
+
const eventOwned = (id) => {
|
|
2378
|
+
const event = id ? deps.events?.get(id) : undefined;
|
|
2379
|
+
return !event || eventOwnerGate(event);
|
|
2380
|
+
};
|
|
2381
|
+
const briefMode = (a) => cfg.wrc.mirror.brief
|
|
2382
|
+
|| !!(a.activeEventId && (deps.events?.get(a.activeEventId)?.payload.peer?.transferred || deps.events?.get(a.activeEventId)?.payload.peer?.from.receipt));
|
|
2383
|
+
const bubbleOwned = (a, b) => {
|
|
2384
|
+
const event = b.owner ? deps.events?.get(b.owner) : undefined;
|
|
2385
|
+
return !event || event.target === a.target && eventOwnerGate(event);
|
|
2386
|
+
};
|
|
2387
|
+
const replyBubbles = new Map();
|
|
2388
|
+
const rememberReplyBubble = (id, bubble) => {
|
|
2389
|
+
if (replyBubbles.has(id))
|
|
2390
|
+
return;
|
|
2391
|
+
replyBubbles.set(id, bubble);
|
|
2392
|
+
setTimeout(() => { if (replyBubbles.get(id) === bubble)
|
|
2393
|
+
replyBubbles.delete(id); }, Math.max(0, (bubble.expires ?? Date.now()) - Date.now()) + 60_000).unref?.();
|
|
2394
|
+
};
|
|
2395
|
+
const claimReplyBubble = (event) => {
|
|
2396
|
+
if (!deps.events || !eventOwnerGate(event) || !event.channel)
|
|
2397
|
+
return undefined;
|
|
2398
|
+
const origin = deps.events.replyOrigin(event);
|
|
2399
|
+
const human = origin?.payload.human;
|
|
2400
|
+
if (!origin || !human)
|
|
2401
|
+
return undefined;
|
|
2402
|
+
const source = byTarget.get(origin.target);
|
|
2403
|
+
const out = source?.outbound;
|
|
2404
|
+
if (source && out?.streamId === human.streamId) {
|
|
2405
|
+
if (out.kind === "deferred")
|
|
2406
|
+
clearTimeout(out.timer);
|
|
2407
|
+
source.outbound = undefined;
|
|
2408
|
+
}
|
|
2409
|
+
let bubble = replyBubbles.get(origin.id);
|
|
2410
|
+
if (!bubble) {
|
|
2411
|
+
const expires = origin.at + HARD_TIMEOUT_MS;
|
|
2412
|
+
if (expires <= Date.now() || deps.events.hasReplyDelivery(human.streamId))
|
|
2413
|
+
return undefined;
|
|
2414
|
+
bubble = { frame: human.frame, streamId: human.streamId, expires, done: false, owner: event.id };
|
|
2415
|
+
rememberReplyBubble(origin.id, bubble);
|
|
2416
|
+
}
|
|
2417
|
+
if (bubble.done || Date.now() >= (bubble.expires ?? 0))
|
|
2418
|
+
return undefined;
|
|
2419
|
+
if (bubble.source) {
|
|
2420
|
+
const stream = bubble.source;
|
|
2421
|
+
stream.closed = true;
|
|
2422
|
+
clearTimeout(stream.flushTimer);
|
|
2423
|
+
clearTimeout(stream.idleTimer);
|
|
2424
|
+
clearTimeout(stream.hardTimer);
|
|
2425
|
+
bubble.pending = stream.pending;
|
|
2426
|
+
const source = byTarget.get(stream.target);
|
|
2427
|
+
if (source?.liveStream === stream)
|
|
2428
|
+
source.liveStream = undefined;
|
|
2429
|
+
bubble.source = undefined;
|
|
2430
|
+
}
|
|
2431
|
+
if (bubble.owner !== event.id) {
|
|
2432
|
+
// The same object carries the last in-flight write into the receiver's FIFO.
|
|
2433
|
+
// Old final/cleanup/timer paths lose authority before this object is reused.
|
|
2434
|
+
byTarget.forEach((a) => {
|
|
2435
|
+
if (a.briefBubble === bubble) {
|
|
2436
|
+
clearCot(a);
|
|
2437
|
+
a.briefBubble = undefined;
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
clearTimeout(bubble.hardTimer);
|
|
2441
|
+
bubble.owner = event.id;
|
|
2442
|
+
}
|
|
2443
|
+
return bubble;
|
|
2444
|
+
};
|
|
2445
|
+
const transferReply = (event) => {
|
|
2446
|
+
const bubble = claimReplyBubble(event);
|
|
2447
|
+
if (!bubble)
|
|
2448
|
+
return;
|
|
2449
|
+
const origin = deps.events?.replyOrigin(event);
|
|
2450
|
+
bubble.pending = (bubble.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2451
|
+
if (bubble.done || bubble.owner !== event.id || !eventOwnerGate(event))
|
|
2452
|
+
return;
|
|
2453
|
+
await client.replyStream(bubble.frame, bubble.streamId, withLinkedTagHeader(event.target, "…", chatTurnUrl(event.target, event.turn || origin?.turn), undefined, undefined, event.channel), false);
|
|
2454
|
+
}).catch((e) => { log.debug({ event: event.id, err: errText(e) }, "transfer reply refresh failed"); });
|
|
2455
|
+
};
|
|
2456
|
+
const armBriefBubble = (a, b, turnId, channel) => {
|
|
2457
|
+
clearTimeout(b.hardTimer);
|
|
2458
|
+
const owner = b.owner;
|
|
2459
|
+
if (!isWxFrame(b.frame))
|
|
2460
|
+
b.hardTimer = setTimeout(() => {
|
|
2461
|
+
if (b.owner !== owner || !bubbleOwned(a, b))
|
|
2462
|
+
return;
|
|
2463
|
+
const live = a.briefBubble === b && !a.briefConcluded;
|
|
2464
|
+
void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
2465
|
+
}, Math.max(0, (b.expires ?? Date.now()) - Date.now()));
|
|
2466
|
+
};
|
|
2369
2467
|
const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2370
2468
|
const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2371
2469
|
const detailCardFor = (s, target) => {
|
|
@@ -2383,13 +2481,13 @@ export const startMirror = (deps) => {
|
|
|
2383
2481
|
};
|
|
2384
2482
|
const flushStream = async (s) => {
|
|
2385
2483
|
s.flushTimer = undefined;
|
|
2386
|
-
if (s.closed || s.dead || s.acc === s.lastSent)
|
|
2484
|
+
if (s.closed || s.dead || s.acc === s.lastSent || !eventOwned(s.eventId))
|
|
2387
2485
|
return;
|
|
2388
2486
|
const content = s.acc;
|
|
2389
2487
|
s.lastSent = content;
|
|
2390
2488
|
try {
|
|
2391
2489
|
await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2392
|
-
if (s.closed || s.dead)
|
|
2490
|
+
if (s.closed || s.dead || !eventOwned(s.eventId))
|
|
2393
2491
|
return;
|
|
2394
2492
|
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
|
|
2395
2493
|
}));
|
|
@@ -2430,10 +2528,12 @@ export const startMirror = (deps) => {
|
|
|
2430
2528
|
// 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
|
|
2431
2529
|
// 也有话可说, 不算空覆盖。
|
|
2432
2530
|
const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
|
|
2433
|
-
if (!s.dead && body.trim()) {
|
|
2531
|
+
if (!s.dead && body.trim() && eventOwned(s.eventId)) {
|
|
2434
2532
|
const card = detailCardFor(s, a.target);
|
|
2435
2533
|
try {
|
|
2436
2534
|
await s.pending?.catch(() => undefined);
|
|
2535
|
+
if (!eventOwned(s.eventId))
|
|
2536
|
+
return;
|
|
2437
2537
|
if (card) {
|
|
2438
2538
|
s.cardSent = true;
|
|
2439
2539
|
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
|
|
@@ -2462,8 +2562,9 @@ export const startMirror = (deps) => {
|
|
|
2462
2562
|
if (a.liveStream === s)
|
|
2463
2563
|
a.liveStream = undefined;
|
|
2464
2564
|
};
|
|
2465
|
-
const openStream = (a, frame, streamId) => {
|
|
2565
|
+
const openStream = (a, frame, streamId, eventId = a.activeEventId) => {
|
|
2466
2566
|
const s = {
|
|
2567
|
+
eventId,
|
|
2467
2568
|
turnId: newTurnId(),
|
|
2468
2569
|
frame, streamId,
|
|
2469
2570
|
target: a.target,
|
|
@@ -2472,6 +2573,11 @@ export const startMirror = (deps) => {
|
|
|
2472
2573
|
capped: false, closed: false, dead: false, cardSent: false,
|
|
2473
2574
|
tools: [], sawTool: false,
|
|
2474
2575
|
};
|
|
2576
|
+
const event = eventId ? deps.events?.get(eventId) : undefined;
|
|
2577
|
+
if (event?.payload.human)
|
|
2578
|
+
rememberReplyBubble(event.id, {
|
|
2579
|
+
frame, streamId, done: false, owner: event.id, source: s, expires: event.at + HARD_TIMEOUT_MS,
|
|
2580
|
+
});
|
|
2475
2581
|
// 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
|
|
2476
2582
|
if (!isWxFrame(frame))
|
|
2477
2583
|
s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
|
|
@@ -2496,7 +2602,8 @@ export const startMirror = (deps) => {
|
|
|
2496
2602
|
// turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
|
|
2497
2603
|
// `[wezard]` 系统提示 (它们回 home)。
|
|
2498
2604
|
const sendStandalone = (a, content) => {
|
|
2499
|
-
|
|
2605
|
+
const eventId = a.activeEventId;
|
|
2606
|
+
if (!hasVisibleBody(content) || !eventOwned(eventId))
|
|
2500
2607
|
return;
|
|
2501
2608
|
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
2502
2609
|
return;
|
|
@@ -2507,6 +2614,8 @@ export const startMirror = (deps) => {
|
|
|
2507
2614
|
a.standalonePending = a.standalonePending
|
|
2508
2615
|
.then(async () => {
|
|
2509
2616
|
for (const c of chunks) {
|
|
2617
|
+
if (!eventOwned(eventId))
|
|
2618
|
+
return;
|
|
2510
2619
|
try {
|
|
2511
2620
|
const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
2512
2621
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
|
|
@@ -2520,12 +2629,15 @@ export const startMirror = (deps) => {
|
|
|
2520
2629
|
};
|
|
2521
2630
|
// Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
|
|
2522
2631
|
const sendRaw = (a, content) => {
|
|
2523
|
-
|
|
2632
|
+
const eventId = a.activeEventId;
|
|
2633
|
+
if (!hasVisibleBody(content) || !eventOwned(eventId))
|
|
2524
2634
|
return;
|
|
2525
2635
|
const chatId = replyChatId(a);
|
|
2526
2636
|
const turnId = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2527
2637
|
a.standalonePending = a.standalonePending
|
|
2528
2638
|
.then(async () => {
|
|
2639
|
+
if (!eventOwned(eventId))
|
|
2640
|
+
return;
|
|
2529
2641
|
try {
|
|
2530
2642
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
2531
2643
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
|
|
@@ -2634,13 +2746,22 @@ export const startMirror = (deps) => {
|
|
|
2634
2746
|
};
|
|
2635
2747
|
// Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
|
|
2636
2748
|
// standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
|
|
2749
|
+
const discardUnownedOutbound = (a, out) => {
|
|
2750
|
+
if (eventOwned(out.eventId))
|
|
2751
|
+
return false;
|
|
2752
|
+
if (out.kind === "deferred")
|
|
2753
|
+
clearTimeout(out.timer);
|
|
2754
|
+
if (a.outbound === out)
|
|
2755
|
+
a.outbound = undefined;
|
|
2756
|
+
return true;
|
|
2757
|
+
};
|
|
2637
2758
|
const promoteToStandalone = (a) => {
|
|
2638
2759
|
const out = a.outbound;
|
|
2639
|
-
if (out?.kind !== "deferred")
|
|
2760
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2640
2761
|
return;
|
|
2641
2762
|
clearTimeout(out.timer);
|
|
2642
2763
|
const md = renderBuf(out.buf);
|
|
2643
|
-
a.outbound = { kind: "awaiting_appr", frame: out.frame, streamId: out.streamId };
|
|
2764
|
+
a.outbound = { kind: "awaiting_appr", eventId: out.eventId, frame: out.frame, streamId: out.streamId };
|
|
2644
2765
|
flushPendingStandalone(a);
|
|
2645
2766
|
if (md)
|
|
2646
2767
|
sendStandalone(a, md);
|
|
@@ -2660,7 +2781,7 @@ export const startMirror = (deps) => {
|
|
|
2660
2781
|
// Pure-text turn or no trailing final text → no split, all into streamId.
|
|
2661
2782
|
const exitDeferredAsFinalStream = (a) => {
|
|
2662
2783
|
const out = a.outbound;
|
|
2663
|
-
if (out?.kind !== "deferred")
|
|
2784
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2664
2785
|
return;
|
|
2665
2786
|
clearTimeout(out.timer);
|
|
2666
2787
|
const buf = out.buf;
|
|
@@ -2685,15 +2806,15 @@ export const startMirror = (deps) => {
|
|
|
2685
2806
|
try {
|
|
2686
2807
|
// 有正文才写旧 streamId (收入 stream); 空缓冲一个字都不写 —— 否则
|
|
2687
2808
|
// loading 气泡被 " " 覆盖, 结束处理自己凭空制造一条空消息。
|
|
2688
|
-
if (bubbleMd)
|
|
2809
|
+
if (bubbleMd && eventOwned(out.eventId))
|
|
2689
2810
|
await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd), true);
|
|
2690
2811
|
}
|
|
2691
2812
|
catch (e) {
|
|
2692
2813
|
log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
|
|
2693
|
-
if (bubbleMd)
|
|
2814
|
+
if (bubbleMd && eventOwned(out.eventId))
|
|
2694
2815
|
sendStandalone(a, bubbleMd);
|
|
2695
2816
|
}
|
|
2696
|
-
if (trailingMd)
|
|
2817
|
+
if (trailingMd && eventOwned(out.eventId))
|
|
2697
2818
|
sendStandalone(a, trailingMd);
|
|
2698
2819
|
})();
|
|
2699
2820
|
log.info({ sessionId: a.sessionId, items: buf.length, bubbleLen: bubbleMd.length, trailingLen: trailingMd.length, sawTool }, "outbound: DEFERRED → IDLE (turn_end, finalized to streamId)");
|
|
@@ -2703,17 +2824,19 @@ export const startMirror = (deps) => {
|
|
|
2703
2824
|
// STREAMING branch since outbound is now undefined.
|
|
2704
2825
|
const promoteToStream = (a) => {
|
|
2705
2826
|
const out = a.outbound;
|
|
2706
|
-
if (out?.kind !== "deferred")
|
|
2827
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2707
2828
|
return;
|
|
2708
2829
|
const { buf, frame, streamId } = out;
|
|
2709
2830
|
a.outbound = undefined;
|
|
2710
|
-
const s = openStream(a, frame, streamId);
|
|
2831
|
+
const s = openStream(a, frame, streamId, out.eventId);
|
|
2711
2832
|
a.liveStream = s;
|
|
2712
2833
|
if (buf.length === 0) {
|
|
2713
2834
|
// Empty buffer — claude still thinking. Send the "…" ack so the user
|
|
2714
2835
|
// sees the bubble; subsequent items grow it as today.
|
|
2715
2836
|
void (async () => {
|
|
2716
2837
|
try {
|
|
2838
|
+
if (!eventOwned(s.eventId) || s.closed)
|
|
2839
|
+
return;
|
|
2717
2840
|
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
2718
2841
|
}
|
|
2719
2842
|
catch (e) {
|
|
@@ -2736,7 +2859,7 @@ export const startMirror = (deps) => {
|
|
|
2736
2859
|
// an empty buffer before any tool_use can be evaluated for approval-needs.
|
|
2737
2860
|
const SAFETY_MS = 5 * 60_000;
|
|
2738
2861
|
const timer = setTimeout(() => promoteToStream(a), SAFETY_MS);
|
|
2739
|
-
a.outbound = { kind: "deferred", buf: [], frame, streamId, timer };
|
|
2862
|
+
a.outbound = { kind: "deferred", eventId: a.activeEventId, buf: [], frame, streamId, timer };
|
|
2740
2863
|
log.info({ sessionId: a.sessionId, safetyMs: SAFETY_MS }, "outbound: IDLE → DEFERRED (safety net)");
|
|
2741
2864
|
};
|
|
2742
2865
|
// Buffer / decide while in DEFERRED. user_text is CLI-side typing (not the
|
|
@@ -2745,7 +2868,7 @@ export const startMirror = (deps) => {
|
|
|
2745
2868
|
// subsequent items don't reset (bounded promote delay).
|
|
2746
2869
|
const handleDeferredItem = (a, item) => {
|
|
2747
2870
|
const out = a.outbound;
|
|
2748
|
-
if (out?.kind !== "deferred")
|
|
2871
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2749
2872
|
return;
|
|
2750
2873
|
if (item.kind === "turn_end") {
|
|
2751
2874
|
exitDeferredAsFinalStream(a);
|
|
@@ -2813,6 +2936,10 @@ export const startMirror = (deps) => {
|
|
|
2813
2936
|
a.cotText = undefined;
|
|
2814
2937
|
a.cotTool = undefined;
|
|
2815
2938
|
};
|
|
2939
|
+
const receiptProgress = (a, text) => {
|
|
2940
|
+
const e = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
2941
|
+
return !e?.payload.peer?.from.receipt || (!e.quiet && !(text && receiptQuiet(e, text)) && eventProgressGate(e));
|
|
2942
|
+
};
|
|
2816
2943
|
const flushCot = async (a) => {
|
|
2817
2944
|
a.cotTimer = undefined;
|
|
2818
2945
|
// 展示选取: assistant text (含 thinking) 优先; 没有新 text 时才落最新 tool_use。
|
|
@@ -2822,12 +2949,12 @@ export const startMirror = (deps) => {
|
|
|
2822
2949
|
const b = a.briefBubble;
|
|
2823
2950
|
const turnId = a.briefTurnId;
|
|
2824
2951
|
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
2825
|
-
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
2952
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, text))
|
|
2826
2953
|
return;
|
|
2827
2954
|
a.cotLastSent = text;
|
|
2828
2955
|
try {
|
|
2829
2956
|
await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2830
|
-
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
|
|
2957
|
+
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b) || !receiptProgress(a, text))
|
|
2831
2958
|
return;
|
|
2832
2959
|
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
|
|
2833
2960
|
}));
|
|
@@ -2839,7 +2966,7 @@ export const startMirror = (deps) => {
|
|
|
2839
2966
|
/** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
|
|
2840
2967
|
const updateBriefProgress = (a, raw) => {
|
|
2841
2968
|
const b = a.briefBubble;
|
|
2842
|
-
if (!b || b.done || a.briefConcluded)
|
|
2969
|
+
if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, raw))
|
|
2843
2970
|
return;
|
|
2844
2971
|
const line = cotLine(raw);
|
|
2845
2972
|
if (!line)
|
|
@@ -2853,7 +2980,7 @@ export const startMirror = (deps) => {
|
|
|
2853
2980
|
* 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
|
|
2854
2981
|
const updateBriefToolProgress = (a, raw) => {
|
|
2855
2982
|
const b = a.briefBubble;
|
|
2856
|
-
if (!b || b.done || a.briefConcluded)
|
|
2983
|
+
if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, raw))
|
|
2857
2984
|
return;
|
|
2858
2985
|
const line = cotLine(raw);
|
|
2859
2986
|
if (!line)
|
|
@@ -2875,7 +3002,7 @@ export const startMirror = (deps) => {
|
|
|
2875
3002
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2876
3003
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2877
3004
|
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2878
|
-
const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
3005
|
+
const turnSilent = (a) => !eventOwned(a.activeEventId) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2879
3006
|
const formatFinal = (target, turn, channel, body) => {
|
|
2880
3007
|
const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
2881
3008
|
return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
|
|
@@ -2885,7 +3012,7 @@ export const startMirror = (deps) => {
|
|
|
2885
3012
|
await finalProgress.get(id)?.catch(() => undefined);
|
|
2886
3013
|
finalProgress.delete(id);
|
|
2887
3014
|
};
|
|
2888
|
-
const queueFinal = (target, turn, channel, body, eventId, bubble) => {
|
|
3015
|
+
const queueFinal = (target, turn, channel, body, eventId, bubble, reportKind = parseClosing(body).kind === "need" ? "need" : "done") => {
|
|
2889
3016
|
// 投递出口剥掉行首收口标记与 ARTIFACT 行 (peers.stripForChat): 判定读原文, 群里只看结论。
|
|
2890
3017
|
const text = stripForChat(body);
|
|
2891
3018
|
if (!deps.events || !channel || !text.trim())
|
|
@@ -2895,21 +3022,25 @@ export const startMirror = (deps) => {
|
|
|
2895
3022
|
const chunks = formatFinal(target, turn, channel, text);
|
|
2896
3023
|
if (bubble?.pending)
|
|
2897
3024
|
finalProgress.set(id, bubble.pending);
|
|
2898
|
-
deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks,
|
|
3025
|
+
const claimed = deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks, reportKind,
|
|
3026
|
+
...(eventId && deps.events.get(eventId) ? { coverage: receiptCoverage(deps.events.get(eventId), channel, parseClosing(body).kind === "need", body) } : {}),
|
|
2899
3027
|
...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
|
|
3028
|
+
if (claimed !== id || deps.events.deliveryStatus(id)?.every((s) => s === "delivered"))
|
|
3029
|
+
finalProgress.delete(id);
|
|
2900
3030
|
if (bubble) {
|
|
2901
3031
|
bubble.done = true;
|
|
2902
3032
|
clearTimeout(bubble.hardTimer);
|
|
2903
3033
|
}
|
|
2904
|
-
return
|
|
3034
|
+
return claimed;
|
|
2905
3035
|
};
|
|
2906
3036
|
const recoverEventFinal = (event, bubble) => {
|
|
2907
|
-
if (!event.result || event.interjected || event.canceled)
|
|
3037
|
+
if ((deps.events?.get(event.id) ?? event).handoffCut || event.quiet || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
|
|
2908
3038
|
return;
|
|
2909
3039
|
// 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
|
|
2910
3040
|
// ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
|
|
2911
3041
|
// 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
|
|
2912
3042
|
// 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
|
|
3043
|
+
bubble ??= claimReplyBubble(event);
|
|
2913
3044
|
bubble ??= (() => {
|
|
2914
3045
|
const a = byTarget.get(event.target);
|
|
2915
3046
|
return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
|
|
@@ -2934,6 +3065,15 @@ export const startMirror = (deps) => {
|
|
|
2934
3065
|
if (event.channel && eventFinalGate(event))
|
|
2935
3066
|
queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
|
|
2936
3067
|
};
|
|
3068
|
+
const deliverTransferredFinal = (event, body, status = "error") => {
|
|
3069
|
+
if (!eventOwnerGate(event) || !event.channel)
|
|
3070
|
+
return;
|
|
3071
|
+
const turn = event.turn || event.id;
|
|
3072
|
+
if (!getDetail(turn))
|
|
3073
|
+
recordTurnStart({ id: turn, eventId: event.id, target: event.target,
|
|
3074
|
+
userQuery: event.payload.text, createdAt: event.at, channel: event.channel, from: event.payload.peer?.from });
|
|
3075
|
+
queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event), status);
|
|
3076
|
+
};
|
|
2937
3077
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2938
3078
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2939
3079
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2942,8 +3082,9 @@ export const startMirror = (deps) => {
|
|
|
2942
3082
|
// standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
|
|
2943
3083
|
const BUBBLE_FINISH_SETTLE_MS = 600;
|
|
2944
3084
|
const finishBubble = async (a, b, content, raw = false) => {
|
|
2945
|
-
if (!b || b.done)
|
|
3085
|
+
if (!b || b.done || !bubbleOwned(a, b))
|
|
2946
3086
|
return;
|
|
3087
|
+
const owner = b.owner;
|
|
2947
3088
|
b.done = true;
|
|
2948
3089
|
clearTimeout(b.hardTimer);
|
|
2949
3090
|
if (a.briefBubble === b)
|
|
@@ -2952,6 +3093,9 @@ export const startMirror = (deps) => {
|
|
|
2952
3093
|
clearCot(a);
|
|
2953
3094
|
const p = (async () => {
|
|
2954
3095
|
try {
|
|
3096
|
+
await b.pending?.catch(() => undefined);
|
|
3097
|
+
if (b.owner !== owner || !bubbleOwned(a, b))
|
|
3098
|
+
return;
|
|
2955
3099
|
await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
|
|
2956
3100
|
// 给 WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
|
|
2957
3101
|
await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
|
|
@@ -2967,62 +3111,6 @@ export const startMirror = (deps) => {
|
|
|
2967
3111
|
return p;
|
|
2968
3112
|
};
|
|
2969
3113
|
const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
|
|
2970
|
-
// ── transfer 的气泡移交 ───────────────────────────────────────────────
|
|
2971
|
-
// 人问的那一笔 transfer 出去, 气泡 (那句人话的 frame/streamId) 跟着行走: 从交出方这一轮摘下,
|
|
2972
|
-
// 按接手方的注入事件 id 寄存, 接手方开出那一轮时 (ensureBriefTurn) 接过去 —— CoT 进度与终句
|
|
2973
|
-
// 续写同一颗气泡, 不另开新的。寄存只在内存: reload 后丢了就退回 standalone, 与过期同。
|
|
2974
|
-
const handedBubbles = new Map();
|
|
2975
|
-
const writeBubble = (b, content, finish) => (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined)
|
|
2976
|
-
.then(() => client.replyStream(b.frame, b.streamId, content, finish))
|
|
2977
|
-
.catch((e) => log.warn({ err: e.message, finish }, "transfer: handed bubble write failed")));
|
|
2978
|
-
/** 6min 窗口的兜底收口按气泡自己的到期点算 (它从人那句话起算, 不因转手重置)。 */
|
|
2979
|
-
const untilExpiry = (b) => Math.max(0, (b.expires ?? Date.now()) - Date.now());
|
|
2980
|
-
const handBubble = (source, serves, eventId, target) => {
|
|
2981
|
-
const a = byTarget.get(source);
|
|
2982
|
-
const b = a?.briefBubble;
|
|
2983
|
-
const turnId = a?.briefTurnId;
|
|
2984
|
-
// 只交「这一轮正服务着那一笔」的气泡: 别的轮的气泡与这次移交无关。
|
|
2985
|
-
if (!a || !b || b.done || !turnId || !a.activeEventId || deps.events?.get(a.activeEventId)?.serves !== serves)
|
|
2986
|
-
return false;
|
|
2987
|
-
clearTimeout(b.hardTimer);
|
|
2988
|
-
a.briefBubble = undefined;
|
|
2989
|
-
clearCot(a);
|
|
2990
|
-
const channel = a.channel || undefined;
|
|
2991
|
-
const line = (rest) => headed(chatHead(target, chatTurnUrl(target, turnId), undefined, channel), rest) || "…";
|
|
2992
|
-
void writeBubble(b, line("…"), false);
|
|
2993
|
-
const held = { bubble: b };
|
|
2994
|
-
// 接手方迟迟不开轮 (在忙 / 被并进别的轮): 到期前收口成「仍在处理中」, 终句届时走 standalone。
|
|
2995
|
-
if (!isWxFrame(b.frame))
|
|
2996
|
-
held.timer = setTimeout(() => {
|
|
2997
|
-
if (handedBubbles.get(eventId) !== held)
|
|
2998
|
-
return;
|
|
2999
|
-
handedBubbles.delete(eventId);
|
|
3000
|
-
b.done = true;
|
|
3001
|
-
void writeBubble(b, line(STILL_WORKING), true);
|
|
3002
|
-
}, untilExpiry(b));
|
|
3003
|
-
handedBubbles.set(eventId, held);
|
|
3004
|
-
log.info({ source, target, serves, eventId, turnId }, "transfer: bubble handed to new owner");
|
|
3005
|
-
return true;
|
|
3006
|
-
};
|
|
3007
|
-
/** 接手方这一轮 (`a.activeEventId`) 若有寄存的气泡: 接成本轮气泡, 头换成它自己的链接。 */
|
|
3008
|
-
const adoptBubble = (a, turnId) => {
|
|
3009
|
-
const held = a.activeEventId ? handedBubbles.get(a.activeEventId) : undefined;
|
|
3010
|
-
if (!held)
|
|
3011
|
-
return undefined;
|
|
3012
|
-
handedBubbles.delete(a.activeEventId);
|
|
3013
|
-
clearTimeout(held.timer);
|
|
3014
|
-
const b = held.bubble;
|
|
3015
|
-
if (b.done || untilExpiry(b) === 0)
|
|
3016
|
-
return undefined;
|
|
3017
|
-
if (!isWxFrame(b.frame))
|
|
3018
|
-
b.hardTimer = setTimeout(() => {
|
|
3019
|
-
const live = a.briefBubble === b && !a.briefConcluded;
|
|
3020
|
-
void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : ""), true);
|
|
3021
|
-
}, untilExpiry(b));
|
|
3022
|
-
void writeBubble(b, briefLine(a, turnId, "…"), false);
|
|
3023
|
-
log.info({ sessionId: a.sessionId, turnId, eventId: a.activeEventId }, "transfer: handed bubble adopted");
|
|
3024
|
-
return b;
|
|
3025
|
-
};
|
|
3026
3114
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
3027
3115
|
const openBriefTurn = (a, q) => {
|
|
3028
3116
|
a.briefTurnId = q.turnId;
|
|
@@ -3065,7 +3153,7 @@ export const startMirror = (deps) => {
|
|
|
3065
3153
|
/** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
|
|
3066
3154
|
const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
|
|
3067
3155
|
const openingOf = (a) => {
|
|
3068
|
-
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
3156
|
+
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
|
|
3069
3157
|
const env = t?.env;
|
|
3070
3158
|
a.activeEventId = env?.eventId;
|
|
3071
3159
|
if (!env)
|
|
@@ -3239,7 +3327,7 @@ export const startMirror = (deps) => {
|
|
|
3239
3327
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
|
|
3240
3328
|
a.pendingBriefQuery = s.query;
|
|
3241
3329
|
a.pendingBriefAt = ts;
|
|
3242
|
-
if (!
|
|
3330
|
+
if (!briefMode(a)) {
|
|
3243
3331
|
consumeChannel(a, false, consumeFrom(a));
|
|
3244
3332
|
if (a.liveStream && !a.liveStream.closed)
|
|
3245
3333
|
a.liveStream.dest = s.channel ?? baseOfKey(a.target);
|
|
@@ -3249,19 +3337,14 @@ export const startMirror = (deps) => {
|
|
|
3249
3337
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
|
|
3250
3338
|
const turnId = newTurnId();
|
|
3251
3339
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
3252
|
-
const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
|
|
3340
|
+
const bubble = { frame, streamId, done: false, owner: eventId, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
|
|
3253
3341
|
if (eventId)
|
|
3254
3342
|
deps.events?.bind(eventId, turnId);
|
|
3255
3343
|
const q = { turnId, bubble, isSlash };
|
|
3256
3344
|
// 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
|
|
3257
|
-
if (
|
|
3258
|
-
bubble
|
|
3259
|
-
|
|
3260
|
-
// 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
|
|
3261
|
-
// 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
|
|
3262
|
-
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
3263
|
-
void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
3264
|
-
}, HARD_TIMEOUT_MS);
|
|
3345
|
+
if (eventId)
|
|
3346
|
+
rememberReplyBubble(eventId, bubble);
|
|
3347
|
+
armBriefBubble(a, bubble, turnId, channel);
|
|
3265
3348
|
const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
3266
3349
|
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
3267
3350
|
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
@@ -3291,7 +3374,7 @@ export const startMirror = (deps) => {
|
|
|
3291
3374
|
if (a.channelSeeded)
|
|
3292
3375
|
return false;
|
|
3293
3376
|
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3294
|
-
const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
3377
|
+
const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
|
|
3295
3378
|
const said = head(opening?.text);
|
|
3296
3379
|
const eventId = opening?.env?.eventId;
|
|
3297
3380
|
const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
|
|
@@ -3308,7 +3391,10 @@ export const startMirror = (deps) => {
|
|
|
3308
3391
|
a.replyTo = publicPeer(r.from);
|
|
3309
3392
|
a.turnFromChat = a.turnFromChat ?? true;
|
|
3310
3393
|
a.briefTurnId = r.id;
|
|
3311
|
-
|
|
3394
|
+
const event = eventId ? deps.events?.get(eventId) : undefined;
|
|
3395
|
+
a.briefBubble = event ? claimReplyBubble(event) : undefined;
|
|
3396
|
+
if (a.briefBubble)
|
|
3397
|
+
armBriefBubble(a, a.briefBubble, r.id, a.channel);
|
|
3312
3398
|
a.briefIsSlash = false;
|
|
3313
3399
|
a.briefHadTool = r.items.some((it) => it.t === "tool_use");
|
|
3314
3400
|
a.briefConcluded = false;
|
|
@@ -3342,15 +3428,15 @@ export const startMirror = (deps) => {
|
|
|
3342
3428
|
a.briefTurnId = turnId;
|
|
3343
3429
|
if (a.activeEventId)
|
|
3344
3430
|
deps.events?.bind(a.activeEventId, turnId);
|
|
3431
|
+
const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
3432
|
+
a.briefBubble = event ? claimReplyBubble(event) : undefined;
|
|
3433
|
+
if (a.briefBubble)
|
|
3434
|
+
armBriefBubble(a, a.briefBubble, turnId, a.channel);
|
|
3345
3435
|
a.briefIsSlash = false;
|
|
3346
3436
|
a.briefHadTool = false;
|
|
3347
3437
|
a.briefConcluded = false;
|
|
3348
3438
|
a.briefLastText = undefined;
|
|
3349
3439
|
clearCot(a);
|
|
3350
|
-
// transfer 接过来的那一笔: 续写人那句话的气泡 —— 这一轮就是在答群里的人。
|
|
3351
|
-
a.briefBubble = adoptBubble(a, turnId);
|
|
3352
|
-
if (a.briefBubble)
|
|
3353
|
-
a.turnFromChat = true;
|
|
3354
3440
|
// 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
|
|
3355
3441
|
// (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
|
|
3356
3442
|
// 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
|
|
@@ -3396,28 +3482,36 @@ export const startMirror = (deps) => {
|
|
|
3396
3482
|
if (event)
|
|
3397
3483
|
deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
|
|
3398
3484
|
const gated = !!event && !eventFinalGate(event);
|
|
3399
|
-
|
|
3400
|
-
if (event && eventHandedAway(event))
|
|
3401
|
-
log.info({ sessionId: a.sessionId, turnId, event: event.id }, "brief: final of a transferred-away row kept off chat");
|
|
3402
|
-
else if (!turnSilent(a) && !gated) {
|
|
3485
|
+
if (!turnSilent(a) && !gated) {
|
|
3403
3486
|
if (event)
|
|
3404
3487
|
recoverEventFinal(deps.events.get(event.id), a.briefBubble);
|
|
3405
3488
|
else
|
|
3406
3489
|
queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
|
|
3407
3490
|
}
|
|
3408
|
-
else if (gated && !turnSilent(a)) {
|
|
3409
|
-
//
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3491
|
+
else if (event && gated && !turnSilent(a) && eventProgressGate(deps.events.get(event.id))) {
|
|
3492
|
+
// A live parent may report progress; settled/canceled rows never use this outlet.
|
|
3493
|
+
const text = stripForChat(body);
|
|
3494
|
+
const channel = event.channel;
|
|
3495
|
+
const id = finalId(event.id, channel, `progress:${text}`);
|
|
3496
|
+
const b = a.briefBubble;
|
|
3497
|
+
if (b?.pending)
|
|
3498
|
+
finalProgress.set(id, b.pending);
|
|
3499
|
+
if (text && channel)
|
|
3500
|
+
deps.events.delivery({ id, eventId: event.id, target: event.target, turn: turnId,
|
|
3501
|
+
channel, body: text, revision: id, purpose: "progress", chunks: formatFinal(event.target, turnId, channel, text),
|
|
3502
|
+
...(b && !b.done ? { stream: { frame: b.frame, id: b.streamId, expires: b.expires ?? Date.now() } } : {}) });
|
|
3503
|
+
if (b) {
|
|
3504
|
+
b.done = true;
|
|
3505
|
+
clearTimeout(b.hardTimer);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
const current = event ? deps.events.get(event.id) : undefined;
|
|
3509
|
+
if (current && (current.quiet || gated && !eventProgressGate(current))) {
|
|
3414
3510
|
const b = a.briefBubble;
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
else if (text.trim())
|
|
3420
|
-
sendStandalone(a, text);
|
|
3511
|
+
if (b) {
|
|
3512
|
+
b.done = true;
|
|
3513
|
+
clearTimeout(b.hardTimer);
|
|
3514
|
+
}
|
|
3421
3515
|
}
|
|
3422
3516
|
// The durable result/outbox must exist before this in-memory dedup bit advances.
|
|
3423
3517
|
a.briefConcluded = true;
|
|
@@ -3835,7 +3929,7 @@ export const startMirror = (deps) => {
|
|
|
3835
3929
|
handleGoalItem(a, { kind: "turn_end" });
|
|
3836
3930
|
return;
|
|
3837
3931
|
}
|
|
3838
|
-
if (
|
|
3932
|
+
if (briefMode(a) && a.briefTurnId) {
|
|
3839
3933
|
closeBriefTurn(a);
|
|
3840
3934
|
return;
|
|
3841
3935
|
}
|
|
@@ -3846,6 +3940,8 @@ export const startMirror = (deps) => {
|
|
|
3846
3940
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
3847
3941
|
if (a.muteUntilInject)
|
|
3848
3942
|
return;
|
|
3943
|
+
if (item.kind === "user_text" || item.kind === "user_query" || item.kind === "user_inject")
|
|
3944
|
+
a.openingOffset = undefined;
|
|
3849
3945
|
if (item.kind === "user_text") {
|
|
3850
3946
|
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3851
3947
|
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
@@ -3864,7 +3960,7 @@ export const startMirror = (deps) => {
|
|
|
3864
3960
|
a.pendingChannel = undefined;
|
|
3865
3961
|
a.activeEventId = item.eventId;
|
|
3866
3962
|
a.pendingFromCli = true;
|
|
3867
|
-
if (!
|
|
3963
|
+
if (!briefMode(a))
|
|
3868
3964
|
consumeChannel(a, true, undefined);
|
|
3869
3965
|
}
|
|
3870
3966
|
if (item.kind === "user_inject") {
|
|
@@ -3911,13 +4007,18 @@ export const startMirror = (deps) => {
|
|
|
3911
4007
|
// 有活跃 turn 时 handleBriefItem 本就丢它, 没有 turn 时 (user_text 不是
|
|
3912
4008
|
// BRIEF_TURN_OPENERS) 它会一路穿到底部的 standalone fallback —— includeUser=true
|
|
3913
4009
|
// 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
|
|
3914
|
-
if (item.kind === "user_text" && (item.quiet ||
|
|
4010
|
+
if (item.kind === "user_text" && (item.quiet || briefMode(a)))
|
|
3915
4011
|
return;
|
|
3916
4012
|
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
3917
4013
|
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
3918
4014
|
// 那就变回已下线的 thinkStyle 了。
|
|
3919
4015
|
if (item.kind === "thinking") {
|
|
3920
|
-
|
|
4016
|
+
const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
4017
|
+
// A receiver can begin with thinking, before any text/tool opener. Bind its
|
|
4018
|
+
// turn and inherited bubble now; pending input alone never authorizes output.
|
|
4019
|
+
if (!a.briefTurnId && event?.payload.peer?.transferred && eventOwnerGate(event))
|
|
4020
|
+
ensureBriefTurn(a);
|
|
4021
|
+
if (briefMode(a) && a.briefTurnId)
|
|
3921
4022
|
updateBriefProgress(a, item.body);
|
|
3922
4023
|
return;
|
|
3923
4024
|
}
|
|
@@ -4060,7 +4161,7 @@ export const startMirror = (deps) => {
|
|
|
4060
4161
|
}
|
|
4061
4162
|
}
|
|
4062
4163
|
}
|
|
4063
|
-
if (!
|
|
4164
|
+
if (!briefMode(a) && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
|
|
4064
4165
|
const e = deps.events.get(a.activeEventId);
|
|
4065
4166
|
if (e) {
|
|
4066
4167
|
// Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
|
|
@@ -4073,7 +4174,7 @@ export const startMirror = (deps) => {
|
|
|
4073
4174
|
return;
|
|
4074
4175
|
}
|
|
4075
4176
|
}
|
|
4076
|
-
if (!
|
|
4177
|
+
if (!briefMode(a) && (isPrivate(a) || !eventOwned(a.activeEventId)))
|
|
4077
4178
|
return;
|
|
4078
4179
|
// /goal mode overrides everything below: the session-scoped Stop hook
|
|
4079
4180
|
// self-drives the model with no terminal stop_reason, so turn_end never fires
|
|
@@ -4092,17 +4193,17 @@ export const startMirror = (deps) => {
|
|
|
4092
4193
|
// Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
|
|
4093
4194
|
// 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
|
|
4094
4195
|
// user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
|
|
4095
|
-
if (
|
|
4196
|
+
if (briefMode(a) && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
|
|
4096
4197
|
ensureBriefTurn(a);
|
|
4097
4198
|
// Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
|
|
4098
4199
|
// 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
|
|
4099
4200
|
// 输出的 item 类型。
|
|
4100
|
-
if (item.kind === "turn_usage" && !(
|
|
4201
|
+
if (item.kind === "turn_usage" && !(briefMode(a) && a.briefTurnId)) {
|
|
4101
4202
|
return;
|
|
4102
4203
|
}
|
|
4103
4204
|
// Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
|
|
4104
4205
|
// 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
|
|
4105
|
-
if (
|
|
4206
|
+
if (briefMode(a) && a.briefTurnId) {
|
|
4106
4207
|
handleBriefItem(a, item);
|
|
4107
4208
|
return;
|
|
4108
4209
|
}
|
|
@@ -4118,7 +4219,7 @@ export const startMirror = (deps) => {
|
|
|
4118
4219
|
return;
|
|
4119
4220
|
// Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
|
|
4120
4221
|
// 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
|
|
4121
|
-
if (
|
|
4222
|
+
if (briefMode(a) && a.briefTurnId) {
|
|
4122
4223
|
handleBriefItem(a, item);
|
|
4123
4224
|
return;
|
|
4124
4225
|
}
|
|
@@ -4133,9 +4234,11 @@ export const startMirror = (deps) => {
|
|
|
4133
4234
|
// 说明 claude 已继续执行 (hook 必然已返回 / 根本没拦), 把状态提升回 STREAMING,
|
|
4134
4235
|
// 复用原 frame/streamId 让本轮回到打字机气泡。
|
|
4135
4236
|
if (a.outbound?.kind === "awaiting_appr") {
|
|
4136
|
-
|
|
4237
|
+
if (discardUnownedOutbound(a, a.outbound))
|
|
4238
|
+
return;
|
|
4239
|
+
const { frame, streamId, eventId } = a.outbound;
|
|
4137
4240
|
a.outbound = undefined;
|
|
4138
|
-
const s = openStream(a, frame, streamId);
|
|
4241
|
+
const s = openStream(a, frame, streamId, eventId);
|
|
4139
4242
|
a.liveStream = s;
|
|
4140
4243
|
log.info({ sessionId: a.sessionId, kind: item.kind, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (hook bypassed, item arrived)");
|
|
4141
4244
|
}
|
|
@@ -4269,11 +4372,21 @@ export const startMirror = (deps) => {
|
|
|
4269
4372
|
const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
|
|
4270
4373
|
if (prevByTarget)
|
|
4271
4374
|
detach(prevByTarget, "target reassigned");
|
|
4375
|
+
const startOffset = (() => { try {
|
|
4376
|
+
return statSync(jsonlPath).size;
|
|
4377
|
+
}
|
|
4378
|
+
catch {
|
|
4379
|
+
return 0;
|
|
4380
|
+
} })();
|
|
4272
4381
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
4273
4382
|
const a = {
|
|
4274
4383
|
sessionId,
|
|
4275
4384
|
jsonlPath,
|
|
4276
4385
|
target,
|
|
4386
|
+
// Tail starts at EOF after reload; recover ownership before its first item,
|
|
4387
|
+
// including non-brief output that never enters ensureBriefTurn.
|
|
4388
|
+
activeEventId: lastUserTurn(jsonlPath, startOffset)?.env?.eventId,
|
|
4389
|
+
openingOffset: startOffset,
|
|
4277
4390
|
tmuxPane: (tmuxPane ?? "").trim(),
|
|
4278
4391
|
tmuxSession: (tmuxSession ?? "").trim(),
|
|
4279
4392
|
// Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
|
|
@@ -4289,6 +4402,7 @@ export const startMirror = (deps) => {
|
|
|
4289
4402
|
};
|
|
4290
4403
|
a.tail = startMirrorTail({
|
|
4291
4404
|
jsonlPath,
|
|
4405
|
+
startOffset,
|
|
4292
4406
|
log: log.child({ sub: "tail", sessionId }),
|
|
4293
4407
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
4294
4408
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
@@ -4678,7 +4792,7 @@ export const startMirror = (deps) => {
|
|
|
4678
4792
|
// freshly-rotated jsonl (only holds the /clear line + new content); resume
|
|
4679
4793
|
// migration omits it (→ tail from EOF) because the fork file is seeded with
|
|
4680
4794
|
// the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
|
|
4681
|
-
const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
|
|
4795
|
+
const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset, knownClear = false) => {
|
|
4682
4796
|
// Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
|
|
4683
4797
|
// prevents silent cross-wire (path B: /clear rotation or drift landing on
|
|
4684
4798
|
// a sibling's live session).
|
|
@@ -4692,7 +4806,7 @@ export const startMirror = (deps) => {
|
|
|
4692
4806
|
// 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
|
|
4693
4807
|
// worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
|
|
4694
4808
|
// drift 上下文是延续的, 画中性的 "switch"。
|
|
4695
|
-
const cleared = jsonlIsPostClearChild(newJsonlPath);
|
|
4809
|
+
const cleared = knownClear || jsonlIsPostClearChild(newJsonlPath);
|
|
4696
4810
|
markCut(a.target, newSessionId, cleared ? "clear" : "switch");
|
|
4697
4811
|
if (cleared)
|
|
4698
4812
|
contextCut(a.target, "clear");
|
|
@@ -4729,6 +4843,8 @@ export const startMirror = (deps) => {
|
|
|
4729
4843
|
tmuxSession: a.tmuxSession || undefined,
|
|
4730
4844
|
tmuxPane: a.tmuxPane || undefined,
|
|
4731
4845
|
cwd: a.runningCwd || undefined,
|
|
4846
|
+
model: a.model || undefined,
|
|
4847
|
+
effort: a.effort || undefined,
|
|
4732
4848
|
pendingCwd: a.pendingCwd || undefined,
|
|
4733
4849
|
});
|
|
4734
4850
|
log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
|
|
@@ -5527,17 +5643,29 @@ export const startMirror = (deps) => {
|
|
|
5527
5643
|
// store — e.g. after a reload that couldn't re-attach — is still killable.
|
|
5528
5644
|
const a = byTarget.get(target);
|
|
5529
5645
|
const pane = a?.tmuxPane || paneOf(target);
|
|
5646
|
+
const sid = a?.sessionId ?? deps.store.get(target)?.sessionId;
|
|
5647
|
+
const birth = wizardStore()?.get(target)?.birthToken;
|
|
5648
|
+
const same = () => byTarget.get(target) === a && (a?.tmuxPane || paneOf(target)) === pane
|
|
5649
|
+
&& (a?.sessionId ?? deps.store.get(target)?.sessionId) === sid && wizardStore()?.get(target)?.birthToken === birth;
|
|
5650
|
+
const replaced = { ok: false, reason: "target identity changed during pane cleanup; cleanup withheld" };
|
|
5530
5651
|
if (!a && !pane)
|
|
5531
5652
|
return { ok: false, reason: "no session bound to target" };
|
|
5532
5653
|
if (pane && (await tmuxPaneAlive(pane))) {
|
|
5654
|
+
if (!same())
|
|
5655
|
+
return replaced;
|
|
5533
5656
|
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
5534
5657
|
// flush its transcript before the TTY is yanked out from under it.
|
|
5535
5658
|
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5536
5659
|
await sleep(250);
|
|
5660
|
+
if (!same())
|
|
5661
|
+
return replaced;
|
|
5537
5662
|
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5538
5663
|
if (r.code !== 0)
|
|
5539
5664
|
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5540
5665
|
}
|
|
5666
|
+
// A late stop must not detach a new wizard that reused this target/name.
|
|
5667
|
+
if (!same())
|
|
5668
|
+
return replaced;
|
|
5541
5669
|
if (a)
|
|
5542
5670
|
detach(a, drop ? "/kill" : "sleep");
|
|
5543
5671
|
if (!drop) {
|
|
@@ -6047,8 +6175,196 @@ export const startMirror = (deps) => {
|
|
|
6047
6175
|
: { ok: true, label: `${pick.index}. ${pick.label}` };
|
|
6048
6176
|
};
|
|
6049
6177
|
const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
|
|
6178
|
+
// 调用方持有 pane 输入锁; clear 与 carry 的原生身份探测共用同一条 slash 提交流程。
|
|
6179
|
+
const nativeCommands = (target, pane, codex, sameProcess, sameSession, allowed = () => true) => {
|
|
6180
|
+
// 每次 slash 都在同一个物理输入锁里核对空框、idle 与进程; 不借用有补 Enter 的通用注入器。
|
|
6181
|
+
const command = async (text, before) => {
|
|
6182
|
+
if (!allowed() || !(await sameProcess()) || !(await idleNow(target)))
|
|
6183
|
+
return false;
|
|
6184
|
+
const input = await readPaneInput(pane);
|
|
6185
|
+
if (!input.box.known || input.box.body || paneIsBusy(input.cap) || isModalPane(input.cap).modal)
|
|
6186
|
+
return false;
|
|
6187
|
+
if (!(await sameSession()))
|
|
6188
|
+
return false;
|
|
6189
|
+
if (!(await sameProcess()))
|
|
6190
|
+
return false;
|
|
6191
|
+
if (!allowed())
|
|
6192
|
+
return false;
|
|
6193
|
+
before?.();
|
|
6194
|
+
if (!allowed())
|
|
6195
|
+
return false;
|
|
6196
|
+
if (!(await runTmux(["send-keys", "-t", pane, "-l", text])).ok)
|
|
6197
|
+
return false;
|
|
6198
|
+
for (const end = Date.now() + 3000; Date.now() < end; await sleep(50)) {
|
|
6199
|
+
const typed = await readPaneInput(pane);
|
|
6200
|
+
if (!(await sameProcess()) || await isBusy(target))
|
|
6201
|
+
return false;
|
|
6202
|
+
if (!(await sameSession()))
|
|
6203
|
+
return false;
|
|
6204
|
+
// Codex 的 slash 菜单会暂时藏掉 model/cwd 状态行, 只留精确命令框与 queue footer。
|
|
6205
|
+
const rows = typed.cap.trimEnd().split("\n");
|
|
6206
|
+
const end = rows.length - 1;
|
|
6207
|
+
const slashBox = codex && /^\s*tab to queue message(?:\s|$)/u.test(rows[end] ?? "")
|
|
6208
|
+
&& !rows[end - 1]?.trim() && !rows[end - 3]?.trim() && rows[end - 2]?.trim() === `› ${text}`;
|
|
6209
|
+
if (((typed.box.known && typed.box.body === text) || slashBox) && !paneIsBusy(typed.cap)) {
|
|
6210
|
+
if (!allowed())
|
|
6211
|
+
return false;
|
|
6212
|
+
return (await runTmux(["send-keys", "-t", pane, "Enter"])).ok;
|
|
6213
|
+
}
|
|
6214
|
+
}
|
|
6215
|
+
return false;
|
|
6216
|
+
};
|
|
6217
|
+
const emptyComposer = async () => {
|
|
6218
|
+
const input = await readPaneInput(pane);
|
|
6219
|
+
return input.box.known && !input.box.body && !paneIsBusy(input.cap) && !isModalPane(input.cap).modal;
|
|
6220
|
+
};
|
|
6221
|
+
const statusId = async () => {
|
|
6222
|
+
const before = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
|
|
6223
|
+
if (!before.ok || !(await command("/status")))
|
|
6224
|
+
return undefined;
|
|
6225
|
+
for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
|
|
6226
|
+
const screen = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
|
|
6227
|
+
if (!screen.ok || !(await sameProcess()))
|
|
6228
|
+
return undefined;
|
|
6229
|
+
const lastStatus = [...screen.stdout.matchAll(/(?:^|\n)(?:›\s*)?\/status\s*\n/gu)].at(-1);
|
|
6230
|
+
const id = [...screen.stdout.matchAll(/\bSession:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gu)].at(-1);
|
|
6231
|
+
if (screen.stdout !== before.stdout && lastStatus && id && id.index > lastStatus.index && await emptyComposer())
|
|
6232
|
+
return id[1];
|
|
6233
|
+
}
|
|
6234
|
+
return undefined;
|
|
6235
|
+
};
|
|
6236
|
+
return { command, emptyComposer, statusId };
|
|
6237
|
+
};
|
|
6238
|
+
const verifyClearContext = async (target, clear) => {
|
|
6239
|
+
const a = byTarget.get(target);
|
|
6240
|
+
if (!a || !clear.nextSid || !clear.panePid || a.tmuxPane !== clear.pane || a.sessionId !== clear.nextSid || await panePidOf(clear.pane) !== clear.panePid)
|
|
6241
|
+
return false;
|
|
6242
|
+
const backend = backendForPath(a.jsonlPath);
|
|
6243
|
+
if (backend.name === "claude") {
|
|
6244
|
+
const actual = sessionOnPane(backend.homeDir, clear.pane);
|
|
6245
|
+
return actual?.pid === clear.pid && actual.sessionId === clear.nextSid;
|
|
6246
|
+
}
|
|
6247
|
+
if (backend.name !== "codex" || codexSessionForPane(clear.pane)?.sessionId !== clear.nextSid)
|
|
6248
|
+
return false;
|
|
6249
|
+
const ps = await psSnap();
|
|
6250
|
+
const pids = new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid));
|
|
6251
|
+
return registeredPidUnder(clear.panePid, pids, ps.kids) === clear.pid;
|
|
6252
|
+
};
|
|
6253
|
+
const verifyClearInput = async (target, clear, phase, allowed) => {
|
|
6254
|
+
if (!allowed() || !(await verifyClearContext(target, clear)))
|
|
6255
|
+
return false;
|
|
6256
|
+
const a = byTarget.get(target);
|
|
6257
|
+
if (phase === "paste" && backendForPath(a.jsonlPath).name === "codex") {
|
|
6258
|
+
// 本机 Codex 不触发 SessionStart; 同 PID 原生 /new 也会让旧映射失真。
|
|
6259
|
+
// brief 尚未粘贴时用 /status 重取当前 ID; submit 阶段由原进程 + 原框内容核验保护。
|
|
6260
|
+
const { statusId } = nativeCommands(target, clear.pane, true, () => verifyClearContext(target, clear), () => true, allowed);
|
|
6261
|
+
return await statusId() === clear.nextSid && await verifyClearContext(target, clear) && allowed();
|
|
6262
|
+
}
|
|
6263
|
+
return true;
|
|
6264
|
+
};
|
|
6265
|
+
const clearContext = async (target, attempt, submitted, guard = () => true) => {
|
|
6266
|
+
if (!guard())
|
|
6267
|
+
return { ok: false, reason: "handoff continuation authority withdrawn; no input" };
|
|
6268
|
+
const a = byTarget.get(target);
|
|
6269
|
+
if (!a?.tmuxPane)
|
|
6270
|
+
return { ok: false, reason: "handoff requires the original live pane; no respawn" };
|
|
6271
|
+
const backend = backendForPath(a.jsonlPath);
|
|
6272
|
+
// 只开放本机真实探针验证过的后端; 不能因家族相近就假定 slash/注册表语义相同。
|
|
6273
|
+
if (!["claude", "codex"].includes(backend.name))
|
|
6274
|
+
return { ok: false, reason: `${backend.name} native /clear completion is not verified; pane unchanged` };
|
|
6275
|
+
if (a.pendingCwd && a.pendingCwd !== a.runningCwd)
|
|
6276
|
+
return { ok: false, reason: "pending workspace switch requires an explicit /new; /clear keeps the original process cwd" };
|
|
6277
|
+
const pane = a.tmuxPane;
|
|
6278
|
+
const valid = () => guard() && byTarget.get(target) === a && a.tmuxPane === pane;
|
|
6279
|
+
try {
|
|
6280
|
+
return await withPaneInput(pane, async (live) => {
|
|
6281
|
+
const codex = backend.name === "codex";
|
|
6282
|
+
const nativePid = async () => {
|
|
6283
|
+
if (!codex)
|
|
6284
|
+
return sessionOnPane(backend.homeDir, pane)?.pid;
|
|
6285
|
+
const root = await panePidOf(pane);
|
|
6286
|
+
const ps = await psSnap();
|
|
6287
|
+
return registeredPidUnder(root, new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)), ps.kids);
|
|
6288
|
+
};
|
|
6289
|
+
const pid = await nativePid();
|
|
6290
|
+
const panePid = await panePidOf(pane);
|
|
6291
|
+
if (!pid || !panePid || !(await tmuxPaneAlive(pane)))
|
|
6292
|
+
return { ok: false, reason: "original CLI process identity is unavailable" };
|
|
6293
|
+
const original = sessionOnPane(backend.homeDir, pane);
|
|
6294
|
+
let transaction = attempt ?? { pane, pid, panePid, sid: a.sessionId, at: Date.now() };
|
|
6295
|
+
if (transaction.pane !== pane || transaction.pid !== pid || (transaction.panePid && transaction.panePid !== panePid))
|
|
6296
|
+
return { ok: false, reason: "original pane/CLI process changed; refusing /clear" };
|
|
6297
|
+
const sameProcess = async () => live() && valid() && await nativePid() === transaction.pid && await panePidOf(pane) === panePid;
|
|
6298
|
+
const { command, emptyComposer, statusId } = nativeCommands(target, pane, codex, sameProcess, () => codex || (sessionOnPane(backend.homeDir, pane)?.sessionId === transaction.sid && sessionOnPane(backend.homeDir, pane)?.status === "idle"), guard);
|
|
6299
|
+
if (!attempt) {
|
|
6300
|
+
const actualSid = codex ? await statusId() : original?.sessionId;
|
|
6301
|
+
if (actualSid !== transaction.sid)
|
|
6302
|
+
return { ok: false, reason: "native pane session changed/unconfirmed before /clear; refusing to clear an unbound context" };
|
|
6303
|
+
// 凭证先于第一下 /clear 按键落盘; reload 只核验原生状态, 绝不再次清空。
|
|
6304
|
+
if (!(await command("/clear", () => submitted(transaction))))
|
|
6305
|
+
return { ok: false, reason: "native /clear submission unconfirmed; no retry" };
|
|
6306
|
+
}
|
|
6307
|
+
if (codex) {
|
|
6308
|
+
let empty = false;
|
|
6309
|
+
for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
|
|
6310
|
+
if (!(await sameProcess()))
|
|
6311
|
+
return { ok: false, reason: "Codex CLI process changed during /clear" };
|
|
6312
|
+
if (await emptyComposer()) {
|
|
6313
|
+
empty = true;
|
|
6314
|
+
break;
|
|
6315
|
+
}
|
|
6316
|
+
}
|
|
6317
|
+
const nextSid = empty ? await statusId() : undefined;
|
|
6318
|
+
if (!nextSid || nextSid === transaction.sid || (transaction.nextSid && transaction.nextSid !== nextSid))
|
|
6319
|
+
return { ok: false, reason: "Codex native /status did not confirm the cleared thread; no retry" };
|
|
6320
|
+
if (sidsClaimedByOthers(target).has(nextSid))
|
|
6321
|
+
return { ok: false, reason: "native Codex thread belongs to another wizard" };
|
|
6322
|
+
transaction = { ...transaction, nextSid };
|
|
6323
|
+
submitted(transaction);
|
|
6324
|
+
const actualRollout = () => codexSessions(expandHome(backend.projectsDir), expandHome(a.runningCwd)).find((s) => s.sessionId === nextSid);
|
|
6325
|
+
// 空 thread 不会先写 rollout; /rename 原生持久化当前 thread, 不发模型消息、不另起 thread。
|
|
6326
|
+
if (!actualRollout() && !(await command(`/rename ${displayName(target).replace(/[\r\n]/gu, " ") || "wezard"}`)))
|
|
6327
|
+
return { ok: false, reason: "Codex /rename persistence unconfirmed; thread preserved" };
|
|
6328
|
+
let rollout = actualRollout();
|
|
6329
|
+
for (const end = Date.now() + 5000; !rollout && Date.now() < end; await sleep(100))
|
|
6330
|
+
rollout = actualRollout();
|
|
6331
|
+
// rename 和 metadata 扫描之间仍可能有人原生切 thread; 最后再读当前 ID, 不绑错现场。
|
|
6332
|
+
if (!rollout || await statusId() !== nextSid || !(await sameProcess()))
|
|
6333
|
+
return { ok: false, reason: "Codex current thread/actual rollout metadata not confirmed; refusing binding" };
|
|
6334
|
+
recordCodexPaneSession({ sessionId: nextSid, jsonlPath: rollout.jsonlPath, cwd: a.runningCwd, pane, startedAt: transaction.at }, cfg.daemon.stateDir);
|
|
6335
|
+
if (a.sessionId !== nextSid)
|
|
6336
|
+
migrateAttachment(a, nextSid, rollout.jsonlPath, 0, true);
|
|
6337
|
+
log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: nextSid, jsonlPath: rollout.jsonlPath }, "handoff: Codex native clear/status/rename confirmed in original CLI process");
|
|
6338
|
+
return { ok: true };
|
|
6339
|
+
}
|
|
6340
|
+
for (const end = Date.now() + 20_000; Date.now() < end; await sleep(200)) {
|
|
6341
|
+
if (!(await sameProcess()))
|
|
6342
|
+
return { ok: false, reason: "CLI process changed during /clear" };
|
|
6343
|
+
const current = sessionOnPane(backend.homeDir, pane);
|
|
6344
|
+
if (!current || current.sessionId === transaction.sid || current.status !== "idle" || !(await emptyComposer()))
|
|
6345
|
+
continue;
|
|
6346
|
+
if (sidsClaimedByOthers(target).has(current.sessionId))
|
|
6347
|
+
return { ok: false, reason: "native session identity belongs to another wizard" };
|
|
6348
|
+
if (transaction.nextSid && transaction.nextSid !== current.sessionId)
|
|
6349
|
+
return { ok: false, reason: "Claude native session changed after clear confirmation" };
|
|
6350
|
+
transaction = { ...transaction, nextSid: current.sessionId };
|
|
6351
|
+
submitted(transaction);
|
|
6352
|
+
if (a.sessionId !== current.sessionId)
|
|
6353
|
+
migrateAttachment(a, current.sessionId, join(dirname(a.jsonlPath), `${current.sessionId}.jsonl`), 0, true);
|
|
6354
|
+
log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: current.sessionId }, "handoff: native /clear confirmed in original CLI process");
|
|
6355
|
+
return { ok: true };
|
|
6356
|
+
}
|
|
6357
|
+
return { ok: false, reason: "native /clear completion unconfirmed; original pane preserved, brief retained, no retry" };
|
|
6358
|
+
}, { guard: valid, timeoutMs: 60_000 });
|
|
6359
|
+
}
|
|
6360
|
+
catch (e) {
|
|
6361
|
+
return { ok: false, reason: String(e) };
|
|
6362
|
+
}
|
|
6363
|
+
};
|
|
6050
6364
|
return {
|
|
6051
6365
|
attach,
|
|
6366
|
+
clearContext,
|
|
6367
|
+
verifyClearContext,
|
|
6052
6368
|
chatTargets,
|
|
6053
6369
|
peers,
|
|
6054
6370
|
foreignPeers,
|
|
@@ -6176,9 +6492,11 @@ export const startMirror = (deps) => {
|
|
|
6176
6492
|
// the first flushStream will reject, mark s.dead=true, and subsequent items
|
|
6177
6493
|
// route to standalone via the existing :1206 fall-through. Self-healing.
|
|
6178
6494
|
if (a.outbound?.kind === "awaiting_appr") {
|
|
6179
|
-
|
|
6495
|
+
if (discardUnownedOutbound(a, a.outbound))
|
|
6496
|
+
return;
|
|
6497
|
+
const { frame, streamId, eventId } = a.outbound;
|
|
6180
6498
|
a.outbound = undefined;
|
|
6181
|
-
const s = openStream(a, frame, streamId);
|
|
6499
|
+
const s = openStream(a, frame, streamId, eventId);
|
|
6182
6500
|
a.liveStream = s;
|
|
6183
6501
|
log.info({ sessionId, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (approved)");
|
|
6184
6502
|
}
|
|
@@ -6279,10 +6597,11 @@ export const startMirror = (deps) => {
|
|
|
6279
6597
|
const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
|
|
6280
6598
|
if (!permission())
|
|
6281
6599
|
return denied;
|
|
6282
|
-
//
|
|
6283
|
-
// 收方真不在了, 报 gone 让上层改投; 还有绑定只是这次 attach 没成, 照旧稍后重试。
|
|
6600
|
+
// A missing binding cannot recover on retry; let receipts escalate.
|
|
6284
6601
|
if (!a)
|
|
6285
6602
|
return { ok: false, retryable: true, reason: "no mirror attached for target", ...(deps.store.get(target) ? {} : { gone: true }) };
|
|
6603
|
+
if (opts?.handoff && !(await verifyClearContext(target, opts.handoff)))
|
|
6604
|
+
return { ok: false, retryable: false, reason: "original cleared CLI process/session changed; handoff carry withheld" };
|
|
6286
6605
|
const sid = a.sessionId;
|
|
6287
6606
|
const generation = a.injectGen ?? 0;
|
|
6288
6607
|
const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
|
|
@@ -6308,6 +6627,8 @@ export const startMirror = (deps) => {
|
|
|
6308
6627
|
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
6309
6628
|
let fresh = a.justSpawned === true;
|
|
6310
6629
|
if (probe.state !== "session") {
|
|
6630
|
+
if (opts?.noRespawn)
|
|
6631
|
+
return { ok: false, retryable: false, reason: "original CLI is unavailable; handoff never respawns" };
|
|
6311
6632
|
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
|
|
6312
6633
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
6313
6634
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
@@ -6356,13 +6677,18 @@ export const startMirror = (deps) => {
|
|
|
6356
6677
|
const clearing = isClearCommand(text);
|
|
6357
6678
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6358
6679
|
const injectPane = a.tmuxPane;
|
|
6680
|
+
let pinRejected = false;
|
|
6359
6681
|
const r = await inject({
|
|
6360
6682
|
text: full, images: [], cfg, guard: (phase, identity) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase, identity)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
6361
6683
|
sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6684
|
+
physicalGuard: opts?.handoff ? async (phase) => { const ok = permission() && await verifyClearInput(target, opts.handoff, phase, permission) && permission(); if (!ok)
|
|
6685
|
+
pinRejected = true; return ok; } : undefined,
|
|
6362
6686
|
requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
|
|
6363
6687
|
prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
|
|
6364
6688
|
beforeInput,
|
|
6365
6689
|
});
|
|
6690
|
+
if (pinRejected)
|
|
6691
|
+
return { ...r, ok: false, retryable: false, reason: "handoff process/session or continuation authority changed at physical input; carry withheld" };
|
|
6366
6692
|
if (r.ok && a.tmuxPane && permitted()) {
|
|
6367
6693
|
if (preClearBaseline) {
|
|
6368
6694
|
a.clearRebind = { baseline: preClearBaseline };
|
|
@@ -6720,9 +7046,11 @@ export const startMirror = (deps) => {
|
|
|
6720
7046
|
},
|
|
6721
7047
|
formatFinal,
|
|
6722
7048
|
recoverEventFinal,
|
|
7049
|
+
deliverTransferredFinal,
|
|
7050
|
+
transferReply,
|
|
6723
7051
|
setEventFinalGate: (gate) => { eventFinalGate = gate; },
|
|
6724
|
-
|
|
6725
|
-
|
|
7052
|
+
setEventProgressGate: (gate) => { eventProgressGate = gate; },
|
|
7053
|
+
setEventOwnerGate: (gate) => { eventOwnerGate = gate; },
|
|
6726
7054
|
settleFinalProgress,
|
|
6727
7055
|
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
|
|
6728
7056
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
@@ -6845,10 +7173,12 @@ export const startMirror = (deps) => {
|
|
|
6845
7173
|
// • outboundDeferMs === 0 → eager openStream + "…" ack (legacy behavior)
|
|
6846
7174
|
const brief = cfg.wrc.mirror.brief && !armMigration;
|
|
6847
7175
|
const eagerOpen = !armMigration && !brief && cfg.wrc.mirror.outboundDeferMs <= 0;
|
|
6848
|
-
const s = eagerOpen ? openStream(a, frame, streamId) : undefined;
|
|
7176
|
+
const s = eagerOpen ? openStream(a, frame, streamId, eventId) : undefined;
|
|
6849
7177
|
if (s) {
|
|
6850
7178
|
a.liveStream = s;
|
|
6851
7179
|
try {
|
|
7180
|
+
if (!eventOwned(s.eventId) || s.closed)
|
|
7181
|
+
return;
|
|
6852
7182
|
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
6853
7183
|
}
|
|
6854
7184
|
catch (e) {
|