wezard 2.7.3 → 2.7.4
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 +345 -208
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/message-events.js +50 -13
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +441 -131
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +6 -6
- package/dist/daemon/peers.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
1
|
import { finalId } 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.
|
|
@@ -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,97 @@ export const startMirror = (deps) => {
|
|
|
2365
2372
|
};
|
|
2366
2373
|
const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
|
|
2367
2374
|
let eventFinalGate = () => true;
|
|
2368
|
-
let
|
|
2375
|
+
let eventOwnerGate = () => true;
|
|
2376
|
+
const eventOwned = (id) => {
|
|
2377
|
+
const event = id ? deps.events?.get(id) : undefined;
|
|
2378
|
+
return !event || eventOwnerGate(event);
|
|
2379
|
+
};
|
|
2380
|
+
const briefMode = (a) => cfg.wrc.mirror.brief
|
|
2381
|
+
|| !!(a.activeEventId && deps.events?.get(a.activeEventId)?.payload.peer?.transferred);
|
|
2382
|
+
const bubbleOwned = (a, b) => {
|
|
2383
|
+
const event = b.owner ? deps.events?.get(b.owner) : undefined;
|
|
2384
|
+
return !event || event.target === a.target && eventOwnerGate(event);
|
|
2385
|
+
};
|
|
2386
|
+
const replyBubbles = new Map();
|
|
2387
|
+
const rememberReplyBubble = (id, bubble) => {
|
|
2388
|
+
if (replyBubbles.has(id))
|
|
2389
|
+
return;
|
|
2390
|
+
replyBubbles.set(id, bubble);
|
|
2391
|
+
setTimeout(() => { if (replyBubbles.get(id) === bubble)
|
|
2392
|
+
replyBubbles.delete(id); }, Math.max(0, (bubble.expires ?? Date.now()) - Date.now()) + 60_000).unref?.();
|
|
2393
|
+
};
|
|
2394
|
+
const claimReplyBubble = (event) => {
|
|
2395
|
+
if (!deps.events || !eventOwnerGate(event) || !event.channel)
|
|
2396
|
+
return undefined;
|
|
2397
|
+
const origin = deps.events.replyOrigin(event);
|
|
2398
|
+
const human = origin?.payload.human;
|
|
2399
|
+
if (!origin || !human)
|
|
2400
|
+
return undefined;
|
|
2401
|
+
const source = byTarget.get(origin.target);
|
|
2402
|
+
const out = source?.outbound;
|
|
2403
|
+
if (source && out?.streamId === human.streamId) {
|
|
2404
|
+
if (out.kind === "deferred")
|
|
2405
|
+
clearTimeout(out.timer);
|
|
2406
|
+
source.outbound = undefined;
|
|
2407
|
+
}
|
|
2408
|
+
let bubble = replyBubbles.get(origin.id);
|
|
2409
|
+
if (!bubble) {
|
|
2410
|
+
const expires = origin.at + HARD_TIMEOUT_MS;
|
|
2411
|
+
if (expires <= Date.now() || deps.events.hasReplyDelivery(human.streamId))
|
|
2412
|
+
return undefined;
|
|
2413
|
+
bubble = { frame: human.frame, streamId: human.streamId, expires, done: false, owner: event.id };
|
|
2414
|
+
rememberReplyBubble(origin.id, bubble);
|
|
2415
|
+
}
|
|
2416
|
+
if (bubble.done || Date.now() >= (bubble.expires ?? 0))
|
|
2417
|
+
return undefined;
|
|
2418
|
+
if (bubble.source) {
|
|
2419
|
+
const stream = bubble.source;
|
|
2420
|
+
stream.closed = true;
|
|
2421
|
+
clearTimeout(stream.flushTimer);
|
|
2422
|
+
clearTimeout(stream.idleTimer);
|
|
2423
|
+
clearTimeout(stream.hardTimer);
|
|
2424
|
+
bubble.pending = stream.pending;
|
|
2425
|
+
const source = byTarget.get(stream.target);
|
|
2426
|
+
if (source?.liveStream === stream)
|
|
2427
|
+
source.liveStream = undefined;
|
|
2428
|
+
bubble.source = undefined;
|
|
2429
|
+
}
|
|
2430
|
+
if (bubble.owner !== event.id) {
|
|
2431
|
+
// The same object carries the last in-flight write into the receiver's FIFO.
|
|
2432
|
+
// Old final/cleanup/timer paths lose authority before this object is reused.
|
|
2433
|
+
byTarget.forEach((a) => {
|
|
2434
|
+
if (a.briefBubble === bubble) {
|
|
2435
|
+
clearCot(a);
|
|
2436
|
+
a.briefBubble = undefined;
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
clearTimeout(bubble.hardTimer);
|
|
2440
|
+
bubble.owner = event.id;
|
|
2441
|
+
}
|
|
2442
|
+
return bubble;
|
|
2443
|
+
};
|
|
2444
|
+
const transferReply = (event) => {
|
|
2445
|
+
const bubble = claimReplyBubble(event);
|
|
2446
|
+
if (!bubble)
|
|
2447
|
+
return;
|
|
2448
|
+
const origin = deps.events?.replyOrigin(event);
|
|
2449
|
+
bubble.pending = (bubble.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2450
|
+
if (bubble.done || bubble.owner !== event.id || !eventOwnerGate(event))
|
|
2451
|
+
return;
|
|
2452
|
+
await client.replyStream(bubble.frame, bubble.streamId, withLinkedTagHeader(event.target, "…", chatTurnUrl(event.target, event.turn || origin?.turn), undefined, undefined, event.channel), false);
|
|
2453
|
+
}).catch((e) => { log.debug({ event: event.id, err: errText(e) }, "transfer reply refresh failed"); });
|
|
2454
|
+
};
|
|
2455
|
+
const armBriefBubble = (a, b, turnId, channel) => {
|
|
2456
|
+
clearTimeout(b.hardTimer);
|
|
2457
|
+
const owner = b.owner;
|
|
2458
|
+
if (!isWxFrame(b.frame))
|
|
2459
|
+
b.hardTimer = setTimeout(() => {
|
|
2460
|
+
if (b.owner !== owner || !bubbleOwned(a, b))
|
|
2461
|
+
return;
|
|
2462
|
+
const live = a.briefBubble === b && !a.briefConcluded;
|
|
2463
|
+
void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
2464
|
+
}, Math.max(0, (b.expires ?? Date.now()) - Date.now()));
|
|
2465
|
+
};
|
|
2369
2466
|
const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2370
2467
|
const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2371
2468
|
const detailCardFor = (s, target) => {
|
|
@@ -2383,13 +2480,13 @@ export const startMirror = (deps) => {
|
|
|
2383
2480
|
};
|
|
2384
2481
|
const flushStream = async (s) => {
|
|
2385
2482
|
s.flushTimer = undefined;
|
|
2386
|
-
if (s.closed || s.dead || s.acc === s.lastSent)
|
|
2483
|
+
if (s.closed || s.dead || s.acc === s.lastSent || !eventOwned(s.eventId))
|
|
2387
2484
|
return;
|
|
2388
2485
|
const content = s.acc;
|
|
2389
2486
|
s.lastSent = content;
|
|
2390
2487
|
try {
|
|
2391
2488
|
await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2392
|
-
if (s.closed || s.dead)
|
|
2489
|
+
if (s.closed || s.dead || !eventOwned(s.eventId))
|
|
2393
2490
|
return;
|
|
2394
2491
|
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
|
|
2395
2492
|
}));
|
|
@@ -2430,10 +2527,12 @@ export const startMirror = (deps) => {
|
|
|
2430
2527
|
// 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
|
|
2431
2528
|
// 也有话可说, 不算空覆盖。
|
|
2432
2529
|
const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
|
|
2433
|
-
if (!s.dead && body.trim()) {
|
|
2530
|
+
if (!s.dead && body.trim() && eventOwned(s.eventId)) {
|
|
2434
2531
|
const card = detailCardFor(s, a.target);
|
|
2435
2532
|
try {
|
|
2436
2533
|
await s.pending?.catch(() => undefined);
|
|
2534
|
+
if (!eventOwned(s.eventId))
|
|
2535
|
+
return;
|
|
2437
2536
|
if (card) {
|
|
2438
2537
|
s.cardSent = true;
|
|
2439
2538
|
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
|
|
@@ -2462,8 +2561,9 @@ export const startMirror = (deps) => {
|
|
|
2462
2561
|
if (a.liveStream === s)
|
|
2463
2562
|
a.liveStream = undefined;
|
|
2464
2563
|
};
|
|
2465
|
-
const openStream = (a, frame, streamId) => {
|
|
2564
|
+
const openStream = (a, frame, streamId, eventId = a.activeEventId) => {
|
|
2466
2565
|
const s = {
|
|
2566
|
+
eventId,
|
|
2467
2567
|
turnId: newTurnId(),
|
|
2468
2568
|
frame, streamId,
|
|
2469
2569
|
target: a.target,
|
|
@@ -2472,6 +2572,11 @@ export const startMirror = (deps) => {
|
|
|
2472
2572
|
capped: false, closed: false, dead: false, cardSent: false,
|
|
2473
2573
|
tools: [], sawTool: false,
|
|
2474
2574
|
};
|
|
2575
|
+
const event = eventId ? deps.events?.get(eventId) : undefined;
|
|
2576
|
+
if (event?.payload.human)
|
|
2577
|
+
rememberReplyBubble(event.id, {
|
|
2578
|
+
frame, streamId, done: false, owner: event.id, source: s, expires: event.at + HARD_TIMEOUT_MS,
|
|
2579
|
+
});
|
|
2475
2580
|
// 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
|
|
2476
2581
|
if (!isWxFrame(frame))
|
|
2477
2582
|
s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
|
|
@@ -2496,7 +2601,8 @@ export const startMirror = (deps) => {
|
|
|
2496
2601
|
// turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
|
|
2497
2602
|
// `[wezard]` 系统提示 (它们回 home)。
|
|
2498
2603
|
const sendStandalone = (a, content) => {
|
|
2499
|
-
|
|
2604
|
+
const eventId = a.activeEventId;
|
|
2605
|
+
if (!hasVisibleBody(content) || !eventOwned(eventId))
|
|
2500
2606
|
return;
|
|
2501
2607
|
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
2502
2608
|
return;
|
|
@@ -2507,6 +2613,8 @@ export const startMirror = (deps) => {
|
|
|
2507
2613
|
a.standalonePending = a.standalonePending
|
|
2508
2614
|
.then(async () => {
|
|
2509
2615
|
for (const c of chunks) {
|
|
2616
|
+
if (!eventOwned(eventId))
|
|
2617
|
+
return;
|
|
2510
2618
|
try {
|
|
2511
2619
|
const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
2512
2620
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
|
|
@@ -2520,12 +2628,15 @@ export const startMirror = (deps) => {
|
|
|
2520
2628
|
};
|
|
2521
2629
|
// Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
|
|
2522
2630
|
const sendRaw = (a, content) => {
|
|
2523
|
-
|
|
2631
|
+
const eventId = a.activeEventId;
|
|
2632
|
+
if (!hasVisibleBody(content) || !eventOwned(eventId))
|
|
2524
2633
|
return;
|
|
2525
2634
|
const chatId = replyChatId(a);
|
|
2526
2635
|
const turnId = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2527
2636
|
a.standalonePending = a.standalonePending
|
|
2528
2637
|
.then(async () => {
|
|
2638
|
+
if (!eventOwned(eventId))
|
|
2639
|
+
return;
|
|
2529
2640
|
try {
|
|
2530
2641
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
2531
2642
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
|
|
@@ -2634,13 +2745,22 @@ export const startMirror = (deps) => {
|
|
|
2634
2745
|
};
|
|
2635
2746
|
// Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
|
|
2636
2747
|
// standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
|
|
2748
|
+
const discardUnownedOutbound = (a, out) => {
|
|
2749
|
+
if (eventOwned(out.eventId))
|
|
2750
|
+
return false;
|
|
2751
|
+
if (out.kind === "deferred")
|
|
2752
|
+
clearTimeout(out.timer);
|
|
2753
|
+
if (a.outbound === out)
|
|
2754
|
+
a.outbound = undefined;
|
|
2755
|
+
return true;
|
|
2756
|
+
};
|
|
2637
2757
|
const promoteToStandalone = (a) => {
|
|
2638
2758
|
const out = a.outbound;
|
|
2639
|
-
if (out?.kind !== "deferred")
|
|
2759
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2640
2760
|
return;
|
|
2641
2761
|
clearTimeout(out.timer);
|
|
2642
2762
|
const md = renderBuf(out.buf);
|
|
2643
|
-
a.outbound = { kind: "awaiting_appr", frame: out.frame, streamId: out.streamId };
|
|
2763
|
+
a.outbound = { kind: "awaiting_appr", eventId: out.eventId, frame: out.frame, streamId: out.streamId };
|
|
2644
2764
|
flushPendingStandalone(a);
|
|
2645
2765
|
if (md)
|
|
2646
2766
|
sendStandalone(a, md);
|
|
@@ -2660,7 +2780,7 @@ export const startMirror = (deps) => {
|
|
|
2660
2780
|
// Pure-text turn or no trailing final text → no split, all into streamId.
|
|
2661
2781
|
const exitDeferredAsFinalStream = (a) => {
|
|
2662
2782
|
const out = a.outbound;
|
|
2663
|
-
if (out?.kind !== "deferred")
|
|
2783
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2664
2784
|
return;
|
|
2665
2785
|
clearTimeout(out.timer);
|
|
2666
2786
|
const buf = out.buf;
|
|
@@ -2685,15 +2805,15 @@ export const startMirror = (deps) => {
|
|
|
2685
2805
|
try {
|
|
2686
2806
|
// 有正文才写旧 streamId (收入 stream); 空缓冲一个字都不写 —— 否则
|
|
2687
2807
|
// loading 气泡被 " " 覆盖, 结束处理自己凭空制造一条空消息。
|
|
2688
|
-
if (bubbleMd)
|
|
2808
|
+
if (bubbleMd && eventOwned(out.eventId))
|
|
2689
2809
|
await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd), true);
|
|
2690
2810
|
}
|
|
2691
2811
|
catch (e) {
|
|
2692
2812
|
log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
|
|
2693
|
-
if (bubbleMd)
|
|
2813
|
+
if (bubbleMd && eventOwned(out.eventId))
|
|
2694
2814
|
sendStandalone(a, bubbleMd);
|
|
2695
2815
|
}
|
|
2696
|
-
if (trailingMd)
|
|
2816
|
+
if (trailingMd && eventOwned(out.eventId))
|
|
2697
2817
|
sendStandalone(a, trailingMd);
|
|
2698
2818
|
})();
|
|
2699
2819
|
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 +2823,19 @@ export const startMirror = (deps) => {
|
|
|
2703
2823
|
// STREAMING branch since outbound is now undefined.
|
|
2704
2824
|
const promoteToStream = (a) => {
|
|
2705
2825
|
const out = a.outbound;
|
|
2706
|
-
if (out?.kind !== "deferred")
|
|
2826
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2707
2827
|
return;
|
|
2708
2828
|
const { buf, frame, streamId } = out;
|
|
2709
2829
|
a.outbound = undefined;
|
|
2710
|
-
const s = openStream(a, frame, streamId);
|
|
2830
|
+
const s = openStream(a, frame, streamId, out.eventId);
|
|
2711
2831
|
a.liveStream = s;
|
|
2712
2832
|
if (buf.length === 0) {
|
|
2713
2833
|
// Empty buffer — claude still thinking. Send the "…" ack so the user
|
|
2714
2834
|
// sees the bubble; subsequent items grow it as today.
|
|
2715
2835
|
void (async () => {
|
|
2716
2836
|
try {
|
|
2837
|
+
if (!eventOwned(s.eventId) || s.closed)
|
|
2838
|
+
return;
|
|
2717
2839
|
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
2718
2840
|
}
|
|
2719
2841
|
catch (e) {
|
|
@@ -2736,7 +2858,7 @@ export const startMirror = (deps) => {
|
|
|
2736
2858
|
// an empty buffer before any tool_use can be evaluated for approval-needs.
|
|
2737
2859
|
const SAFETY_MS = 5 * 60_000;
|
|
2738
2860
|
const timer = setTimeout(() => promoteToStream(a), SAFETY_MS);
|
|
2739
|
-
a.outbound = { kind: "deferred", buf: [], frame, streamId, timer };
|
|
2861
|
+
a.outbound = { kind: "deferred", eventId: a.activeEventId, buf: [], frame, streamId, timer };
|
|
2740
2862
|
log.info({ sessionId: a.sessionId, safetyMs: SAFETY_MS }, "outbound: IDLE → DEFERRED (safety net)");
|
|
2741
2863
|
};
|
|
2742
2864
|
// Buffer / decide while in DEFERRED. user_text is CLI-side typing (not the
|
|
@@ -2745,7 +2867,7 @@ export const startMirror = (deps) => {
|
|
|
2745
2867
|
// subsequent items don't reset (bounded promote delay).
|
|
2746
2868
|
const handleDeferredItem = (a, item) => {
|
|
2747
2869
|
const out = a.outbound;
|
|
2748
|
-
if (out?.kind !== "deferred")
|
|
2870
|
+
if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
|
|
2749
2871
|
return;
|
|
2750
2872
|
if (item.kind === "turn_end") {
|
|
2751
2873
|
exitDeferredAsFinalStream(a);
|
|
@@ -2822,12 +2944,12 @@ export const startMirror = (deps) => {
|
|
|
2822
2944
|
const b = a.briefBubble;
|
|
2823
2945
|
const turnId = a.briefTurnId;
|
|
2824
2946
|
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
2825
|
-
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
2947
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b))
|
|
2826
2948
|
return;
|
|
2827
2949
|
a.cotLastSent = text;
|
|
2828
2950
|
try {
|
|
2829
2951
|
await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2830
|
-
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
|
|
2952
|
+
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b))
|
|
2831
2953
|
return;
|
|
2832
2954
|
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
|
|
2833
2955
|
}));
|
|
@@ -2839,7 +2961,7 @@ export const startMirror = (deps) => {
|
|
|
2839
2961
|
/** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
|
|
2840
2962
|
const updateBriefProgress = (a, raw) => {
|
|
2841
2963
|
const b = a.briefBubble;
|
|
2842
|
-
if (!b || b.done || a.briefConcluded)
|
|
2964
|
+
if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
|
|
2843
2965
|
return;
|
|
2844
2966
|
const line = cotLine(raw);
|
|
2845
2967
|
if (!line)
|
|
@@ -2853,7 +2975,7 @@ export const startMirror = (deps) => {
|
|
|
2853
2975
|
* 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
|
|
2854
2976
|
const updateBriefToolProgress = (a, raw) => {
|
|
2855
2977
|
const b = a.briefBubble;
|
|
2856
|
-
if (!b || b.done || a.briefConcluded)
|
|
2978
|
+
if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
|
|
2857
2979
|
return;
|
|
2858
2980
|
const line = cotLine(raw);
|
|
2859
2981
|
if (!line)
|
|
@@ -2875,7 +2997,7 @@ export const startMirror = (deps) => {
|
|
|
2875
2997
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2876
2998
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2877
2999
|
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2878
|
-
const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
3000
|
+
const turnSilent = (a) => !eventOwned(a.activeEventId) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2879
3001
|
const formatFinal = (target, turn, channel, body) => {
|
|
2880
3002
|
const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
2881
3003
|
return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
|
|
@@ -2904,12 +3026,13 @@ export const startMirror = (deps) => {
|
|
|
2904
3026
|
return id;
|
|
2905
3027
|
};
|
|
2906
3028
|
const recoverEventFinal = (event, bubble) => {
|
|
2907
|
-
if (!event.result || event.interjected || event.canceled)
|
|
3029
|
+
if ((deps.events?.get(event.id) ?? event).handoffCut || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
|
|
2908
3030
|
return;
|
|
2909
3031
|
// 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
|
|
2910
3032
|
// ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
|
|
2911
3033
|
// 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
|
|
2912
3034
|
// 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
|
|
3035
|
+
bubble ??= claimReplyBubble(event);
|
|
2913
3036
|
bubble ??= (() => {
|
|
2914
3037
|
const a = byTarget.get(event.target);
|
|
2915
3038
|
return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
|
|
@@ -2934,6 +3057,15 @@ export const startMirror = (deps) => {
|
|
|
2934
3057
|
if (event.channel && eventFinalGate(event))
|
|
2935
3058
|
queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
|
|
2936
3059
|
};
|
|
3060
|
+
const deliverTransferredFinal = (event, body) => {
|
|
3061
|
+
if (!eventOwnerGate(event) || !event.channel)
|
|
3062
|
+
return;
|
|
3063
|
+
const turn = event.turn || event.id;
|
|
3064
|
+
if (!getDetail(turn))
|
|
3065
|
+
recordTurnStart({ id: turn, eventId: event.id, target: event.target,
|
|
3066
|
+
userQuery: event.payload.text, createdAt: event.at, channel: event.channel, from: event.payload.peer?.from });
|
|
3067
|
+
queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event));
|
|
3068
|
+
};
|
|
2937
3069
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2938
3070
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2939
3071
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2942,8 +3074,9 @@ export const startMirror = (deps) => {
|
|
|
2942
3074
|
// standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
|
|
2943
3075
|
const BUBBLE_FINISH_SETTLE_MS = 600;
|
|
2944
3076
|
const finishBubble = async (a, b, content, raw = false) => {
|
|
2945
|
-
if (!b || b.done)
|
|
3077
|
+
if (!b || b.done || !bubbleOwned(a, b))
|
|
2946
3078
|
return;
|
|
3079
|
+
const owner = b.owner;
|
|
2947
3080
|
b.done = true;
|
|
2948
3081
|
clearTimeout(b.hardTimer);
|
|
2949
3082
|
if (a.briefBubble === b)
|
|
@@ -2952,6 +3085,9 @@ export const startMirror = (deps) => {
|
|
|
2952
3085
|
clearCot(a);
|
|
2953
3086
|
const p = (async () => {
|
|
2954
3087
|
try {
|
|
3088
|
+
await b.pending?.catch(() => undefined);
|
|
3089
|
+
if (b.owner !== owner || !bubbleOwned(a, b))
|
|
3090
|
+
return;
|
|
2955
3091
|
await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
|
|
2956
3092
|
// 给 WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
|
|
2957
3093
|
await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
|
|
@@ -2967,62 +3103,6 @@ export const startMirror = (deps) => {
|
|
|
2967
3103
|
return p;
|
|
2968
3104
|
};
|
|
2969
3105
|
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
3106
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
3027
3107
|
const openBriefTurn = (a, q) => {
|
|
3028
3108
|
a.briefTurnId = q.turnId;
|
|
@@ -3065,7 +3145,7 @@ export const startMirror = (deps) => {
|
|
|
3065
3145
|
/** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
|
|
3066
3146
|
const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
|
|
3067
3147
|
const openingOf = (a) => {
|
|
3068
|
-
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
3148
|
+
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
|
|
3069
3149
|
const env = t?.env;
|
|
3070
3150
|
a.activeEventId = env?.eventId;
|
|
3071
3151
|
if (!env)
|
|
@@ -3239,7 +3319,7 @@ export const startMirror = (deps) => {
|
|
|
3239
3319
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
|
|
3240
3320
|
a.pendingBriefQuery = s.query;
|
|
3241
3321
|
a.pendingBriefAt = ts;
|
|
3242
|
-
if (!
|
|
3322
|
+
if (!briefMode(a)) {
|
|
3243
3323
|
consumeChannel(a, false, consumeFrom(a));
|
|
3244
3324
|
if (a.liveStream && !a.liveStream.closed)
|
|
3245
3325
|
a.liveStream.dest = s.channel ?? baseOfKey(a.target);
|
|
@@ -3249,19 +3329,14 @@ export const startMirror = (deps) => {
|
|
|
3249
3329
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
|
|
3250
3330
|
const turnId = newTurnId();
|
|
3251
3331
|
// 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 };
|
|
3332
|
+
const bubble = { frame, streamId, done: false, owner: eventId, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
|
|
3253
3333
|
if (eventId)
|
|
3254
3334
|
deps.events?.bind(eventId, turnId);
|
|
3255
3335
|
const q = { turnId, bubble, isSlash };
|
|
3256
3336
|
// 微信帧不挂: 没有 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);
|
|
3337
|
+
if (eventId)
|
|
3338
|
+
rememberReplyBubble(eventId, bubble);
|
|
3339
|
+
armBriefBubble(a, bubble, turnId, channel);
|
|
3265
3340
|
const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
3266
3341
|
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
3267
3342
|
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
@@ -3291,7 +3366,7 @@ export const startMirror = (deps) => {
|
|
|
3291
3366
|
if (a.channelSeeded)
|
|
3292
3367
|
return false;
|
|
3293
3368
|
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3294
|
-
const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
3369
|
+
const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
|
|
3295
3370
|
const said = head(opening?.text);
|
|
3296
3371
|
const eventId = opening?.env?.eventId;
|
|
3297
3372
|
const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
|
|
@@ -3308,7 +3383,10 @@ export const startMirror = (deps) => {
|
|
|
3308
3383
|
a.replyTo = publicPeer(r.from);
|
|
3309
3384
|
a.turnFromChat = a.turnFromChat ?? true;
|
|
3310
3385
|
a.briefTurnId = r.id;
|
|
3311
|
-
|
|
3386
|
+
const event = eventId ? deps.events?.get(eventId) : undefined;
|
|
3387
|
+
a.briefBubble = event ? claimReplyBubble(event) : undefined;
|
|
3388
|
+
if (a.briefBubble)
|
|
3389
|
+
armBriefBubble(a, a.briefBubble, r.id, a.channel);
|
|
3312
3390
|
a.briefIsSlash = false;
|
|
3313
3391
|
a.briefHadTool = r.items.some((it) => it.t === "tool_use");
|
|
3314
3392
|
a.briefConcluded = false;
|
|
@@ -3342,15 +3420,15 @@ export const startMirror = (deps) => {
|
|
|
3342
3420
|
a.briefTurnId = turnId;
|
|
3343
3421
|
if (a.activeEventId)
|
|
3344
3422
|
deps.events?.bind(a.activeEventId, turnId);
|
|
3423
|
+
const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
3424
|
+
a.briefBubble = event ? claimReplyBubble(event) : undefined;
|
|
3425
|
+
if (a.briefBubble)
|
|
3426
|
+
armBriefBubble(a, a.briefBubble, turnId, a.channel);
|
|
3345
3427
|
a.briefIsSlash = false;
|
|
3346
3428
|
a.briefHadTool = false;
|
|
3347
3429
|
a.briefConcluded = false;
|
|
3348
3430
|
a.briefLastText = undefined;
|
|
3349
3431
|
clearCot(a);
|
|
3350
|
-
// transfer 接过来的那一笔: 续写人那句话的气泡 —— 这一轮就是在答群里的人。
|
|
3351
|
-
a.briefBubble = adoptBubble(a, turnId);
|
|
3352
|
-
if (a.briefBubble)
|
|
3353
|
-
a.turnFromChat = true;
|
|
3354
3432
|
// 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
|
|
3355
3433
|
// (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
|
|
3356
3434
|
// 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
|
|
@@ -3396,16 +3474,13 @@ export const startMirror = (deps) => {
|
|
|
3396
3474
|
if (event)
|
|
3397
3475
|
deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
|
|
3398
3476
|
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) {
|
|
3477
|
+
if (!turnSilent(a) && !gated) {
|
|
3403
3478
|
if (event)
|
|
3404
3479
|
recoverEventFinal(deps.events.get(event.id), a.briefBubble);
|
|
3405
3480
|
else
|
|
3406
3481
|
queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
|
|
3407
3482
|
}
|
|
3408
|
-
else if (gated && !turnSilent(a)) {
|
|
3483
|
+
else if (gated && eventOwned(a.activeEventId) && !turnSilent(a)) {
|
|
3409
3484
|
// 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
|
|
3410
3485
|
// queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
|
|
3411
3486
|
// use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
|
|
@@ -3835,7 +3910,7 @@ export const startMirror = (deps) => {
|
|
|
3835
3910
|
handleGoalItem(a, { kind: "turn_end" });
|
|
3836
3911
|
return;
|
|
3837
3912
|
}
|
|
3838
|
-
if (
|
|
3913
|
+
if (briefMode(a) && a.briefTurnId) {
|
|
3839
3914
|
closeBriefTurn(a);
|
|
3840
3915
|
return;
|
|
3841
3916
|
}
|
|
@@ -3846,6 +3921,8 @@ export const startMirror = (deps) => {
|
|
|
3846
3921
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
3847
3922
|
if (a.muteUntilInject)
|
|
3848
3923
|
return;
|
|
3924
|
+
if (item.kind === "user_text" || item.kind === "user_query" || item.kind === "user_inject")
|
|
3925
|
+
a.openingOffset = undefined;
|
|
3849
3926
|
if (item.kind === "user_text") {
|
|
3850
3927
|
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3851
3928
|
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
@@ -3864,7 +3941,7 @@ export const startMirror = (deps) => {
|
|
|
3864
3941
|
a.pendingChannel = undefined;
|
|
3865
3942
|
a.activeEventId = item.eventId;
|
|
3866
3943
|
a.pendingFromCli = true;
|
|
3867
|
-
if (!
|
|
3944
|
+
if (!briefMode(a))
|
|
3868
3945
|
consumeChannel(a, true, undefined);
|
|
3869
3946
|
}
|
|
3870
3947
|
if (item.kind === "user_inject") {
|
|
@@ -3911,13 +3988,18 @@ export const startMirror = (deps) => {
|
|
|
3911
3988
|
// 有活跃 turn 时 handleBriefItem 本就丢它, 没有 turn 时 (user_text 不是
|
|
3912
3989
|
// BRIEF_TURN_OPENERS) 它会一路穿到底部的 standalone fallback —— includeUser=true
|
|
3913
3990
|
// 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
|
|
3914
|
-
if (item.kind === "user_text" && (item.quiet ||
|
|
3991
|
+
if (item.kind === "user_text" && (item.quiet || briefMode(a)))
|
|
3915
3992
|
return;
|
|
3916
3993
|
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
3917
3994
|
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
3918
3995
|
// 那就变回已下线的 thinkStyle 了。
|
|
3919
3996
|
if (item.kind === "thinking") {
|
|
3920
|
-
|
|
3997
|
+
const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
3998
|
+
// A receiver can begin with thinking, before any text/tool opener. Bind its
|
|
3999
|
+
// turn and inherited bubble now; pending input alone never authorizes output.
|
|
4000
|
+
if (!a.briefTurnId && event?.payload.peer?.transferred && eventOwnerGate(event))
|
|
4001
|
+
ensureBriefTurn(a);
|
|
4002
|
+
if (briefMode(a) && a.briefTurnId)
|
|
3921
4003
|
updateBriefProgress(a, item.body);
|
|
3922
4004
|
return;
|
|
3923
4005
|
}
|
|
@@ -4060,7 +4142,7 @@ export const startMirror = (deps) => {
|
|
|
4060
4142
|
}
|
|
4061
4143
|
}
|
|
4062
4144
|
}
|
|
4063
|
-
if (!
|
|
4145
|
+
if (!briefMode(a) && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
|
|
4064
4146
|
const e = deps.events.get(a.activeEventId);
|
|
4065
4147
|
if (e) {
|
|
4066
4148
|
// Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
|
|
@@ -4073,7 +4155,7 @@ export const startMirror = (deps) => {
|
|
|
4073
4155
|
return;
|
|
4074
4156
|
}
|
|
4075
4157
|
}
|
|
4076
|
-
if (!
|
|
4158
|
+
if (!briefMode(a) && (isPrivate(a) || !eventOwned(a.activeEventId)))
|
|
4077
4159
|
return;
|
|
4078
4160
|
// /goal mode overrides everything below: the session-scoped Stop hook
|
|
4079
4161
|
// self-drives the model with no terminal stop_reason, so turn_end never fires
|
|
@@ -4092,17 +4174,17 @@ export const startMirror = (deps) => {
|
|
|
4092
4174
|
// Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
|
|
4093
4175
|
// 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
|
|
4094
4176
|
// user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
|
|
4095
|
-
if (
|
|
4177
|
+
if (briefMode(a) && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
|
|
4096
4178
|
ensureBriefTurn(a);
|
|
4097
4179
|
// Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
|
|
4098
4180
|
// 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
|
|
4099
4181
|
// 输出的 item 类型。
|
|
4100
|
-
if (item.kind === "turn_usage" && !(
|
|
4182
|
+
if (item.kind === "turn_usage" && !(briefMode(a) && a.briefTurnId)) {
|
|
4101
4183
|
return;
|
|
4102
4184
|
}
|
|
4103
4185
|
// Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
|
|
4104
4186
|
// 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
|
|
4105
|
-
if (
|
|
4187
|
+
if (briefMode(a) && a.briefTurnId) {
|
|
4106
4188
|
handleBriefItem(a, item);
|
|
4107
4189
|
return;
|
|
4108
4190
|
}
|
|
@@ -4118,7 +4200,7 @@ export const startMirror = (deps) => {
|
|
|
4118
4200
|
return;
|
|
4119
4201
|
// Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
|
|
4120
4202
|
// 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
|
|
4121
|
-
if (
|
|
4203
|
+
if (briefMode(a) && a.briefTurnId) {
|
|
4122
4204
|
handleBriefItem(a, item);
|
|
4123
4205
|
return;
|
|
4124
4206
|
}
|
|
@@ -4133,9 +4215,11 @@ export const startMirror = (deps) => {
|
|
|
4133
4215
|
// 说明 claude 已继续执行 (hook 必然已返回 / 根本没拦), 把状态提升回 STREAMING,
|
|
4134
4216
|
// 复用原 frame/streamId 让本轮回到打字机气泡。
|
|
4135
4217
|
if (a.outbound?.kind === "awaiting_appr") {
|
|
4136
|
-
|
|
4218
|
+
if (discardUnownedOutbound(a, a.outbound))
|
|
4219
|
+
return;
|
|
4220
|
+
const { frame, streamId, eventId } = a.outbound;
|
|
4137
4221
|
a.outbound = undefined;
|
|
4138
|
-
const s = openStream(a, frame, streamId);
|
|
4222
|
+
const s = openStream(a, frame, streamId, eventId);
|
|
4139
4223
|
a.liveStream = s;
|
|
4140
4224
|
log.info({ sessionId: a.sessionId, kind: item.kind, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (hook bypassed, item arrived)");
|
|
4141
4225
|
}
|
|
@@ -4269,11 +4353,21 @@ export const startMirror = (deps) => {
|
|
|
4269
4353
|
const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
|
|
4270
4354
|
if (prevByTarget)
|
|
4271
4355
|
detach(prevByTarget, "target reassigned");
|
|
4356
|
+
const startOffset = (() => { try {
|
|
4357
|
+
return statSync(jsonlPath).size;
|
|
4358
|
+
}
|
|
4359
|
+
catch {
|
|
4360
|
+
return 0;
|
|
4361
|
+
} })();
|
|
4272
4362
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
4273
4363
|
const a = {
|
|
4274
4364
|
sessionId,
|
|
4275
4365
|
jsonlPath,
|
|
4276
4366
|
target,
|
|
4367
|
+
// Tail starts at EOF after reload; recover ownership before its first item,
|
|
4368
|
+
// including non-brief output that never enters ensureBriefTurn.
|
|
4369
|
+
activeEventId: lastUserTurn(jsonlPath, startOffset)?.env?.eventId,
|
|
4370
|
+
openingOffset: startOffset,
|
|
4277
4371
|
tmuxPane: (tmuxPane ?? "").trim(),
|
|
4278
4372
|
tmuxSession: (tmuxSession ?? "").trim(),
|
|
4279
4373
|
// Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
|
|
@@ -4289,6 +4383,7 @@ export const startMirror = (deps) => {
|
|
|
4289
4383
|
};
|
|
4290
4384
|
a.tail = startMirrorTail({
|
|
4291
4385
|
jsonlPath,
|
|
4386
|
+
startOffset,
|
|
4292
4387
|
log: log.child({ sub: "tail", sessionId }),
|
|
4293
4388
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
4294
4389
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
@@ -4678,7 +4773,7 @@ export const startMirror = (deps) => {
|
|
|
4678
4773
|
// freshly-rotated jsonl (only holds the /clear line + new content); resume
|
|
4679
4774
|
// migration omits it (→ tail from EOF) because the fork file is seeded with
|
|
4680
4775
|
// the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
|
|
4681
|
-
const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
|
|
4776
|
+
const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset, knownClear = false) => {
|
|
4682
4777
|
// Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
|
|
4683
4778
|
// prevents silent cross-wire (path B: /clear rotation or drift landing on
|
|
4684
4779
|
// a sibling's live session).
|
|
@@ -4692,7 +4787,7 @@ export const startMirror = (deps) => {
|
|
|
4692
4787
|
// 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
|
|
4693
4788
|
// worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
|
|
4694
4789
|
// drift 上下文是延续的, 画中性的 "switch"。
|
|
4695
|
-
const cleared = jsonlIsPostClearChild(newJsonlPath);
|
|
4790
|
+
const cleared = knownClear || jsonlIsPostClearChild(newJsonlPath);
|
|
4696
4791
|
markCut(a.target, newSessionId, cleared ? "clear" : "switch");
|
|
4697
4792
|
if (cleared)
|
|
4698
4793
|
contextCut(a.target, "clear");
|
|
@@ -4729,6 +4824,8 @@ export const startMirror = (deps) => {
|
|
|
4729
4824
|
tmuxSession: a.tmuxSession || undefined,
|
|
4730
4825
|
tmuxPane: a.tmuxPane || undefined,
|
|
4731
4826
|
cwd: a.runningCwd || undefined,
|
|
4827
|
+
model: a.model || undefined,
|
|
4828
|
+
effort: a.effort || undefined,
|
|
4732
4829
|
pendingCwd: a.pendingCwd || undefined,
|
|
4733
4830
|
});
|
|
4734
4831
|
log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
|
|
@@ -5527,17 +5624,29 @@ export const startMirror = (deps) => {
|
|
|
5527
5624
|
// store — e.g. after a reload that couldn't re-attach — is still killable.
|
|
5528
5625
|
const a = byTarget.get(target);
|
|
5529
5626
|
const pane = a?.tmuxPane || paneOf(target);
|
|
5627
|
+
const sid = a?.sessionId ?? deps.store.get(target)?.sessionId;
|
|
5628
|
+
const birth = wizardStore()?.get(target)?.birthToken;
|
|
5629
|
+
const same = () => byTarget.get(target) === a && (a?.tmuxPane || paneOf(target)) === pane
|
|
5630
|
+
&& (a?.sessionId ?? deps.store.get(target)?.sessionId) === sid && wizardStore()?.get(target)?.birthToken === birth;
|
|
5631
|
+
const replaced = { ok: false, reason: "target identity changed during pane cleanup; cleanup withheld" };
|
|
5530
5632
|
if (!a && !pane)
|
|
5531
5633
|
return { ok: false, reason: "no session bound to target" };
|
|
5532
5634
|
if (pane && (await tmuxPaneAlive(pane))) {
|
|
5635
|
+
if (!same())
|
|
5636
|
+
return replaced;
|
|
5533
5637
|
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
5534
5638
|
// flush its transcript before the TTY is yanked out from under it.
|
|
5535
5639
|
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5536
5640
|
await sleep(250);
|
|
5641
|
+
if (!same())
|
|
5642
|
+
return replaced;
|
|
5537
5643
|
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5538
5644
|
if (r.code !== 0)
|
|
5539
5645
|
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5540
5646
|
}
|
|
5647
|
+
// A late stop must not detach a new wizard that reused this target/name.
|
|
5648
|
+
if (!same())
|
|
5649
|
+
return replaced;
|
|
5541
5650
|
if (a)
|
|
5542
5651
|
detach(a, drop ? "/kill" : "sleep");
|
|
5543
5652
|
if (!drop) {
|
|
@@ -6047,8 +6156,196 @@ export const startMirror = (deps) => {
|
|
|
6047
6156
|
: { ok: true, label: `${pick.index}. ${pick.label}` };
|
|
6048
6157
|
};
|
|
6049
6158
|
const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
|
|
6159
|
+
// 调用方持有 pane 输入锁; clear 与 carry 的原生身份探测共用同一条 slash 提交流程。
|
|
6160
|
+
const nativeCommands = (target, pane, codex, sameProcess, sameSession, allowed = () => true) => {
|
|
6161
|
+
// 每次 slash 都在同一个物理输入锁里核对空框、idle 与进程; 不借用有补 Enter 的通用注入器。
|
|
6162
|
+
const command = async (text, before) => {
|
|
6163
|
+
if (!allowed() || !(await sameProcess()) || !(await idleNow(target)))
|
|
6164
|
+
return false;
|
|
6165
|
+
const input = await readPaneInput(pane);
|
|
6166
|
+
if (!input.box.known || input.box.body || paneIsBusy(input.cap) || isModalPane(input.cap).modal)
|
|
6167
|
+
return false;
|
|
6168
|
+
if (!(await sameSession()))
|
|
6169
|
+
return false;
|
|
6170
|
+
if (!(await sameProcess()))
|
|
6171
|
+
return false;
|
|
6172
|
+
if (!allowed())
|
|
6173
|
+
return false;
|
|
6174
|
+
before?.();
|
|
6175
|
+
if (!allowed())
|
|
6176
|
+
return false;
|
|
6177
|
+
if (!(await runTmux(["send-keys", "-t", pane, "-l", text])).ok)
|
|
6178
|
+
return false;
|
|
6179
|
+
for (const end = Date.now() + 3000; Date.now() < end; await sleep(50)) {
|
|
6180
|
+
const typed = await readPaneInput(pane);
|
|
6181
|
+
if (!(await sameProcess()) || await isBusy(target))
|
|
6182
|
+
return false;
|
|
6183
|
+
if (!(await sameSession()))
|
|
6184
|
+
return false;
|
|
6185
|
+
// Codex 的 slash 菜单会暂时藏掉 model/cwd 状态行, 只留精确命令框与 queue footer。
|
|
6186
|
+
const rows = typed.cap.trimEnd().split("\n");
|
|
6187
|
+
const end = rows.length - 1;
|
|
6188
|
+
const slashBox = codex && /^\s*tab to queue message(?:\s|$)/u.test(rows[end] ?? "")
|
|
6189
|
+
&& !rows[end - 1]?.trim() && !rows[end - 3]?.trim() && rows[end - 2]?.trim() === `› ${text}`;
|
|
6190
|
+
if (((typed.box.known && typed.box.body === text) || slashBox) && !paneIsBusy(typed.cap)) {
|
|
6191
|
+
if (!allowed())
|
|
6192
|
+
return false;
|
|
6193
|
+
return (await runTmux(["send-keys", "-t", pane, "Enter"])).ok;
|
|
6194
|
+
}
|
|
6195
|
+
}
|
|
6196
|
+
return false;
|
|
6197
|
+
};
|
|
6198
|
+
const emptyComposer = async () => {
|
|
6199
|
+
const input = await readPaneInput(pane);
|
|
6200
|
+
return input.box.known && !input.box.body && !paneIsBusy(input.cap) && !isModalPane(input.cap).modal;
|
|
6201
|
+
};
|
|
6202
|
+
const statusId = async () => {
|
|
6203
|
+
const before = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
|
|
6204
|
+
if (!before.ok || !(await command("/status")))
|
|
6205
|
+
return undefined;
|
|
6206
|
+
for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
|
|
6207
|
+
const screen = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
|
|
6208
|
+
if (!screen.ok || !(await sameProcess()))
|
|
6209
|
+
return undefined;
|
|
6210
|
+
const lastStatus = [...screen.stdout.matchAll(/(?:^|\n)(?:›\s*)?\/status\s*\n/gu)].at(-1);
|
|
6211
|
+
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);
|
|
6212
|
+
if (screen.stdout !== before.stdout && lastStatus && id && id.index > lastStatus.index && await emptyComposer())
|
|
6213
|
+
return id[1];
|
|
6214
|
+
}
|
|
6215
|
+
return undefined;
|
|
6216
|
+
};
|
|
6217
|
+
return { command, emptyComposer, statusId };
|
|
6218
|
+
};
|
|
6219
|
+
const verifyClearContext = async (target, clear) => {
|
|
6220
|
+
const a = byTarget.get(target);
|
|
6221
|
+
if (!a || !clear.nextSid || !clear.panePid || a.tmuxPane !== clear.pane || a.sessionId !== clear.nextSid || await panePidOf(clear.pane) !== clear.panePid)
|
|
6222
|
+
return false;
|
|
6223
|
+
const backend = backendForPath(a.jsonlPath);
|
|
6224
|
+
if (backend.name === "claude") {
|
|
6225
|
+
const actual = sessionOnPane(backend.homeDir, clear.pane);
|
|
6226
|
+
return actual?.pid === clear.pid && actual.sessionId === clear.nextSid;
|
|
6227
|
+
}
|
|
6228
|
+
if (backend.name !== "codex" || codexSessionForPane(clear.pane)?.sessionId !== clear.nextSid)
|
|
6229
|
+
return false;
|
|
6230
|
+
const ps = await psSnap();
|
|
6231
|
+
const pids = new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid));
|
|
6232
|
+
return registeredPidUnder(clear.panePid, pids, ps.kids) === clear.pid;
|
|
6233
|
+
};
|
|
6234
|
+
const verifyClearInput = async (target, clear, phase, allowed) => {
|
|
6235
|
+
if (!allowed() || !(await verifyClearContext(target, clear)))
|
|
6236
|
+
return false;
|
|
6237
|
+
const a = byTarget.get(target);
|
|
6238
|
+
if (phase === "paste" && backendForPath(a.jsonlPath).name === "codex") {
|
|
6239
|
+
// 本机 Codex 不触发 SessionStart; 同 PID 原生 /new 也会让旧映射失真。
|
|
6240
|
+
// brief 尚未粘贴时用 /status 重取当前 ID; submit 阶段由原进程 + 原框内容核验保护。
|
|
6241
|
+
const { statusId } = nativeCommands(target, clear.pane, true, () => verifyClearContext(target, clear), () => true, allowed);
|
|
6242
|
+
return await statusId() === clear.nextSid && await verifyClearContext(target, clear) && allowed();
|
|
6243
|
+
}
|
|
6244
|
+
return true;
|
|
6245
|
+
};
|
|
6246
|
+
const clearContext = async (target, attempt, submitted, guard = () => true) => {
|
|
6247
|
+
if (!guard())
|
|
6248
|
+
return { ok: false, reason: "handoff continuation authority withdrawn; no input" };
|
|
6249
|
+
const a = byTarget.get(target);
|
|
6250
|
+
if (!a?.tmuxPane)
|
|
6251
|
+
return { ok: false, reason: "handoff requires the original live pane; no respawn" };
|
|
6252
|
+
const backend = backendForPath(a.jsonlPath);
|
|
6253
|
+
// 只开放本机真实探针验证过的后端; 不能因家族相近就假定 slash/注册表语义相同。
|
|
6254
|
+
if (!["claude", "codex"].includes(backend.name))
|
|
6255
|
+
return { ok: false, reason: `${backend.name} native /clear completion is not verified; pane unchanged` };
|
|
6256
|
+
if (a.pendingCwd && a.pendingCwd !== a.runningCwd)
|
|
6257
|
+
return { ok: false, reason: "pending workspace switch requires an explicit /new; /clear keeps the original process cwd" };
|
|
6258
|
+
const pane = a.tmuxPane;
|
|
6259
|
+
const valid = () => guard() && byTarget.get(target) === a && a.tmuxPane === pane;
|
|
6260
|
+
try {
|
|
6261
|
+
return await withPaneInput(pane, async (live) => {
|
|
6262
|
+
const codex = backend.name === "codex";
|
|
6263
|
+
const nativePid = async () => {
|
|
6264
|
+
if (!codex)
|
|
6265
|
+
return sessionOnPane(backend.homeDir, pane)?.pid;
|
|
6266
|
+
const root = await panePidOf(pane);
|
|
6267
|
+
const ps = await psSnap();
|
|
6268
|
+
return registeredPidUnder(root, new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)), ps.kids);
|
|
6269
|
+
};
|
|
6270
|
+
const pid = await nativePid();
|
|
6271
|
+
const panePid = await panePidOf(pane);
|
|
6272
|
+
if (!pid || !panePid || !(await tmuxPaneAlive(pane)))
|
|
6273
|
+
return { ok: false, reason: "original CLI process identity is unavailable" };
|
|
6274
|
+
const original = sessionOnPane(backend.homeDir, pane);
|
|
6275
|
+
let transaction = attempt ?? { pane, pid, panePid, sid: a.sessionId, at: Date.now() };
|
|
6276
|
+
if (transaction.pane !== pane || transaction.pid !== pid || (transaction.panePid && transaction.panePid !== panePid))
|
|
6277
|
+
return { ok: false, reason: "original pane/CLI process changed; refusing /clear" };
|
|
6278
|
+
const sameProcess = async () => live() && valid() && await nativePid() === transaction.pid && await panePidOf(pane) === panePid;
|
|
6279
|
+
const { command, emptyComposer, statusId } = nativeCommands(target, pane, codex, sameProcess, () => codex || (sessionOnPane(backend.homeDir, pane)?.sessionId === transaction.sid && sessionOnPane(backend.homeDir, pane)?.status === "idle"), guard);
|
|
6280
|
+
if (!attempt) {
|
|
6281
|
+
const actualSid = codex ? await statusId() : original?.sessionId;
|
|
6282
|
+
if (actualSid !== transaction.sid)
|
|
6283
|
+
return { ok: false, reason: "native pane session changed/unconfirmed before /clear; refusing to clear an unbound context" };
|
|
6284
|
+
// 凭证先于第一下 /clear 按键落盘; reload 只核验原生状态, 绝不再次清空。
|
|
6285
|
+
if (!(await command("/clear", () => submitted(transaction))))
|
|
6286
|
+
return { ok: false, reason: "native /clear submission unconfirmed; no retry" };
|
|
6287
|
+
}
|
|
6288
|
+
if (codex) {
|
|
6289
|
+
let empty = false;
|
|
6290
|
+
for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
|
|
6291
|
+
if (!(await sameProcess()))
|
|
6292
|
+
return { ok: false, reason: "Codex CLI process changed during /clear" };
|
|
6293
|
+
if (await emptyComposer()) {
|
|
6294
|
+
empty = true;
|
|
6295
|
+
break;
|
|
6296
|
+
}
|
|
6297
|
+
}
|
|
6298
|
+
const nextSid = empty ? await statusId() : undefined;
|
|
6299
|
+
if (!nextSid || nextSid === transaction.sid || (transaction.nextSid && transaction.nextSid !== nextSid))
|
|
6300
|
+
return { ok: false, reason: "Codex native /status did not confirm the cleared thread; no retry" };
|
|
6301
|
+
if (sidsClaimedByOthers(target).has(nextSid))
|
|
6302
|
+
return { ok: false, reason: "native Codex thread belongs to another wizard" };
|
|
6303
|
+
transaction = { ...transaction, nextSid };
|
|
6304
|
+
submitted(transaction);
|
|
6305
|
+
const actualRollout = () => codexSessions(expandHome(backend.projectsDir), expandHome(a.runningCwd)).find((s) => s.sessionId === nextSid);
|
|
6306
|
+
// 空 thread 不会先写 rollout; /rename 原生持久化当前 thread, 不发模型消息、不另起 thread。
|
|
6307
|
+
if (!actualRollout() && !(await command(`/rename ${displayName(target).replace(/[\r\n]/gu, " ") || "wezard"}`)))
|
|
6308
|
+
return { ok: false, reason: "Codex /rename persistence unconfirmed; thread preserved" };
|
|
6309
|
+
let rollout = actualRollout();
|
|
6310
|
+
for (const end = Date.now() + 5000; !rollout && Date.now() < end; await sleep(100))
|
|
6311
|
+
rollout = actualRollout();
|
|
6312
|
+
// rename 和 metadata 扫描之间仍可能有人原生切 thread; 最后再读当前 ID, 不绑错现场。
|
|
6313
|
+
if (!rollout || await statusId() !== nextSid || !(await sameProcess()))
|
|
6314
|
+
return { ok: false, reason: "Codex current thread/actual rollout metadata not confirmed; refusing binding" };
|
|
6315
|
+
recordCodexPaneSession({ sessionId: nextSid, jsonlPath: rollout.jsonlPath, cwd: a.runningCwd, pane, startedAt: transaction.at }, cfg.daemon.stateDir);
|
|
6316
|
+
if (a.sessionId !== nextSid)
|
|
6317
|
+
migrateAttachment(a, nextSid, rollout.jsonlPath, 0, true);
|
|
6318
|
+
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");
|
|
6319
|
+
return { ok: true };
|
|
6320
|
+
}
|
|
6321
|
+
for (const end = Date.now() + 20_000; Date.now() < end; await sleep(200)) {
|
|
6322
|
+
if (!(await sameProcess()))
|
|
6323
|
+
return { ok: false, reason: "CLI process changed during /clear" };
|
|
6324
|
+
const current = sessionOnPane(backend.homeDir, pane);
|
|
6325
|
+
if (!current || current.sessionId === transaction.sid || current.status !== "idle" || !(await emptyComposer()))
|
|
6326
|
+
continue;
|
|
6327
|
+
if (sidsClaimedByOthers(target).has(current.sessionId))
|
|
6328
|
+
return { ok: false, reason: "native session identity belongs to another wizard" };
|
|
6329
|
+
if (transaction.nextSid && transaction.nextSid !== current.sessionId)
|
|
6330
|
+
return { ok: false, reason: "Claude native session changed after clear confirmation" };
|
|
6331
|
+
transaction = { ...transaction, nextSid: current.sessionId };
|
|
6332
|
+
submitted(transaction);
|
|
6333
|
+
if (a.sessionId !== current.sessionId)
|
|
6334
|
+
migrateAttachment(a, current.sessionId, join(dirname(a.jsonlPath), `${current.sessionId}.jsonl`), 0, true);
|
|
6335
|
+
log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: current.sessionId }, "handoff: native /clear confirmed in original CLI process");
|
|
6336
|
+
return { ok: true };
|
|
6337
|
+
}
|
|
6338
|
+
return { ok: false, reason: "native /clear completion unconfirmed; original pane preserved, brief retained, no retry" };
|
|
6339
|
+
}, { guard: valid, timeoutMs: 60_000 });
|
|
6340
|
+
}
|
|
6341
|
+
catch (e) {
|
|
6342
|
+
return { ok: false, reason: String(e) };
|
|
6343
|
+
}
|
|
6344
|
+
};
|
|
6050
6345
|
return {
|
|
6051
6346
|
attach,
|
|
6347
|
+
clearContext,
|
|
6348
|
+
verifyClearContext,
|
|
6052
6349
|
chatTargets,
|
|
6053
6350
|
peers,
|
|
6054
6351
|
foreignPeers,
|
|
@@ -6176,9 +6473,11 @@ export const startMirror = (deps) => {
|
|
|
6176
6473
|
// the first flushStream will reject, mark s.dead=true, and subsequent items
|
|
6177
6474
|
// route to standalone via the existing :1206 fall-through. Self-healing.
|
|
6178
6475
|
if (a.outbound?.kind === "awaiting_appr") {
|
|
6179
|
-
|
|
6476
|
+
if (discardUnownedOutbound(a, a.outbound))
|
|
6477
|
+
return;
|
|
6478
|
+
const { frame, streamId, eventId } = a.outbound;
|
|
6180
6479
|
a.outbound = undefined;
|
|
6181
|
-
const s = openStream(a, frame, streamId);
|
|
6480
|
+
const s = openStream(a, frame, streamId, eventId);
|
|
6182
6481
|
a.liveStream = s;
|
|
6183
6482
|
log.info({ sessionId, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (approved)");
|
|
6184
6483
|
}
|
|
@@ -6279,10 +6578,11 @@ export const startMirror = (deps) => {
|
|
|
6279
6578
|
const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
|
|
6280
6579
|
if (!permission())
|
|
6281
6580
|
return denied;
|
|
6282
|
-
//
|
|
6283
|
-
// 收方真不在了, 报 gone 让上层改投; 还有绑定只是这次 attach 没成, 照旧稍后重试。
|
|
6581
|
+
// A missing binding cannot recover on retry; let receipts escalate.
|
|
6284
6582
|
if (!a)
|
|
6285
6583
|
return { ok: false, retryable: true, reason: "no mirror attached for target", ...(deps.store.get(target) ? {} : { gone: true }) };
|
|
6584
|
+
if (opts?.handoff && !(await verifyClearContext(target, opts.handoff)))
|
|
6585
|
+
return { ok: false, retryable: false, reason: "original cleared CLI process/session changed; handoff carry withheld" };
|
|
6286
6586
|
const sid = a.sessionId;
|
|
6287
6587
|
const generation = a.injectGen ?? 0;
|
|
6288
6588
|
const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
|
|
@@ -6308,6 +6608,8 @@ export const startMirror = (deps) => {
|
|
|
6308
6608
|
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
6309
6609
|
let fresh = a.justSpawned === true;
|
|
6310
6610
|
if (probe.state !== "session") {
|
|
6611
|
+
if (opts?.noRespawn)
|
|
6612
|
+
return { ok: false, retryable: false, reason: "original CLI is unavailable; handoff never respawns" };
|
|
6311
6613
|
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
|
|
6312
6614
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
6313
6615
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
@@ -6356,13 +6658,18 @@ export const startMirror = (deps) => {
|
|
|
6356
6658
|
const clearing = isClearCommand(text);
|
|
6357
6659
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6358
6660
|
const injectPane = a.tmuxPane;
|
|
6661
|
+
let pinRejected = false;
|
|
6359
6662
|
const r = await inject({
|
|
6360
6663
|
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
6664
|
sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6665
|
+
physicalGuard: opts?.handoff ? async (phase) => { const ok = permission() && await verifyClearInput(target, opts.handoff, phase, permission) && permission(); if (!ok)
|
|
6666
|
+
pinRejected = true; return ok; } : undefined,
|
|
6362
6667
|
requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
|
|
6363
6668
|
prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
|
|
6364
6669
|
beforeInput,
|
|
6365
6670
|
});
|
|
6671
|
+
if (pinRejected)
|
|
6672
|
+
return { ...r, ok: false, retryable: false, reason: "handoff process/session or continuation authority changed at physical input; carry withheld" };
|
|
6366
6673
|
if (r.ok && a.tmuxPane && permitted()) {
|
|
6367
6674
|
if (preClearBaseline) {
|
|
6368
6675
|
a.clearRebind = { baseline: preClearBaseline };
|
|
@@ -6720,9 +7027,10 @@ export const startMirror = (deps) => {
|
|
|
6720
7027
|
},
|
|
6721
7028
|
formatFinal,
|
|
6722
7029
|
recoverEventFinal,
|
|
7030
|
+
deliverTransferredFinal,
|
|
7031
|
+
transferReply,
|
|
6723
7032
|
setEventFinalGate: (gate) => { eventFinalGate = gate; },
|
|
6724
|
-
|
|
6725
|
-
handBubble,
|
|
7033
|
+
setEventOwnerGate: (gate) => { eventOwnerGate = gate; },
|
|
6726
7034
|
settleFinalProgress,
|
|
6727
7035
|
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
|
|
6728
7036
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
@@ -6845,10 +7153,12 @@ export const startMirror = (deps) => {
|
|
|
6845
7153
|
// • outboundDeferMs === 0 → eager openStream + "…" ack (legacy behavior)
|
|
6846
7154
|
const brief = cfg.wrc.mirror.brief && !armMigration;
|
|
6847
7155
|
const eagerOpen = !armMigration && !brief && cfg.wrc.mirror.outboundDeferMs <= 0;
|
|
6848
|
-
const s = eagerOpen ? openStream(a, frame, streamId) : undefined;
|
|
7156
|
+
const s = eagerOpen ? openStream(a, frame, streamId, eventId) : undefined;
|
|
6849
7157
|
if (s) {
|
|
6850
7158
|
a.liveStream = s;
|
|
6851
7159
|
try {
|
|
7160
|
+
if (!eventOwned(s.eventId) || s.closed)
|
|
7161
|
+
return;
|
|
6852
7162
|
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
6853
7163
|
}
|
|
6854
7164
|
catch (e) {
|