wezard 2.2.2 → 2.4.0

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.
Files changed (112) hide show
  1. package/CLAUDE.md +9 -6
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/cli/init.js +2 -1
  5. package/dist/cli/init.js.map +1 -1
  6. package/dist/daemon/approval.js +90 -6
  7. package/dist/daemon/approval.js.map +1 -1
  8. package/dist/daemon/chat-log.js +6 -1
  9. package/dist/daemon/chat-log.js.map +1 -1
  10. package/dist/daemon/chat-name.js +15 -0
  11. package/dist/daemon/chat-name.js.map +1 -1
  12. package/dist/daemon/config-api.js +265 -70
  13. package/dist/daemon/config-api.js.map +1 -1
  14. package/dist/daemon/danger.js +15 -0
  15. package/dist/daemon/danger.js.map +1 -1
  16. package/dist/daemon/detail.js +49 -0
  17. package/dist/daemon/detail.js.map +1 -1
  18. package/dist/daemon/handoff.js +12 -5
  19. package/dist/daemon/handoff.js.map +1 -1
  20. package/dist/daemon/idle-nudge.js +55 -0
  21. package/dist/daemon/idle-nudge.js.map +1 -0
  22. package/dist/daemon/inbound.js +19 -14
  23. package/dist/daemon/inbound.js.map +1 -1
  24. package/dist/daemon/index.js +1509 -259
  25. package/dist/daemon/index.js.map +1 -1
  26. package/dist/daemon/jobs.js +180 -32
  27. package/dist/daemon/jobs.js.map +1 -1
  28. package/dist/daemon/last-response.js +15 -0
  29. package/dist/daemon/last-response.js.map +1 -1
  30. package/dist/daemon/memory-steward.js +124 -68
  31. package/dist/daemon/memory-steward.js.map +1 -1
  32. package/dist/daemon/mirror-bridge.js +506 -124
  33. package/dist/daemon/mirror-bridge.js.map +1 -1
  34. package/dist/daemon/mirror-store.js.map +1 -1
  35. package/dist/daemon/model-select.js +102 -0
  36. package/dist/daemon/model-select.js.map +1 -1
  37. package/dist/daemon/notices.js +147 -33
  38. package/dist/daemon/notices.js.map +1 -1
  39. package/dist/daemon/peers.js +261 -124
  40. package/dist/daemon/peers.js.map +1 -1
  41. package/dist/daemon/pending-items.js +171 -0
  42. package/dist/daemon/pending-items.js.map +1 -0
  43. package/dist/daemon/receipts.js +428 -80
  44. package/dist/daemon/receipts.js.map +1 -1
  45. package/dist/daemon/route.js +133 -19
  46. package/dist/daemon/route.js.map +1 -1
  47. package/dist/daemon/spawn-tmux.js +24 -13
  48. package/dist/daemon/spawn-tmux.js.map +1 -1
  49. package/dist/daemon/steward-guard.js +32 -0
  50. package/dist/daemon/steward-guard.js.map +1 -0
  51. package/dist/daemon/task-registry.js +1 -1
  52. package/dist/daemon/task-registry.js.map +1 -1
  53. package/dist/daemon/tasks.js +12 -4
  54. package/dist/daemon/tasks.js.map +1 -1
  55. package/dist/daemon/usage.js +4 -11
  56. package/dist/daemon/usage.js.map +1 -1
  57. package/dist/daemon/wizard-memory.js +36 -4
  58. package/dist/daemon/wizard-memory.js.map +1 -1
  59. package/dist/daemon/wizard.js +174 -87
  60. package/dist/daemon/wizard.js.map +1 -1
  61. package/dist/mcp/server.js +209 -132
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/shared/charter-view.js +35 -0
  64. package/dist/shared/charter-view.js.map +1 -0
  65. package/dist/shared/chat-http.js +134 -32
  66. package/dist/shared/chat-http.js.map +1 -1
  67. package/dist/shared/chat-render.js +6 -3
  68. package/dist/shared/chat-render.js.map +1 -1
  69. package/dist/shared/chat-view.js +17 -6
  70. package/dist/shared/chat-view.js.map +1 -1
  71. package/dist/shared/cli-backends.js +2 -0
  72. package/dist/shared/cli-backends.js.map +1 -1
  73. package/dist/shared/config-meta.js +91 -0
  74. package/dist/shared/config-meta.js.map +1 -0
  75. package/dist/shared/config-writer.js +5 -6
  76. package/dist/shared/config-writer.js.map +1 -1
  77. package/dist/shared/config.js +273 -294
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/daemon-token.js +18 -0
  80. package/dist/shared/daemon-token.js.map +1 -0
  81. package/dist/shared/detail-render.js +233 -20
  82. package/dist/shared/detail-render.js.map +1 -1
  83. package/dist/shared/detail-store.js +33 -12
  84. package/dist/shared/detail-store.js.map +1 -1
  85. package/dist/shared/effort.js +11 -0
  86. package/dist/shared/effort.js.map +1 -0
  87. package/dist/shared/keepalive.js +5 -5
  88. package/dist/shared/keepalive.js.map +1 -1
  89. package/dist/shared/model-prices.js +258 -0
  90. package/dist/shared/model-prices.js.map +1 -0
  91. package/dist/shared/pricing.js +55 -0
  92. package/dist/shared/pricing.js.map +1 -0
  93. package/dist/shared/reminder.js +18 -4
  94. package/dist/shared/reminder.js.map +1 -1
  95. package/dist/shared/role-render.js +210 -8
  96. package/dist/shared/role-render.js.map +1 -1
  97. package/dist/shared/role-view.js +104 -26
  98. package/dist/shared/role-view.js.map +1 -1
  99. package/dist/shared/session-label.js +4 -0
  100. package/dist/shared/session-label.js.map +1 -1
  101. package/dist/shared/task-file.js +16 -1
  102. package/dist/shared/task-file.js.map +1 -1
  103. package/dist/shared/turn-state.js +24 -0
  104. package/dist/shared/turn-state.js.map +1 -0
  105. package/dist/shared/world.js +116 -13
  106. package/dist/shared/world.js.map +1 -1
  107. package/dist/svr/index.js +2 -1
  108. package/dist/svr/index.js.map +1 -1
  109. package/hooks/pre-tool-use.sh +15 -3
  110. package/package.json +1 -1
  111. package/web/chat.css +226 -74
  112. package/web/chat.js +1075 -370
@@ -13,29 +13,32 @@
13
13
  // Caveat: the user shouldn't be hammering the same session in their local TTY
14
14
  // while a `--resume` injection is in flight; Claude Code locks aren't strict.
15
15
  import { spawn } from "node:child_process";
16
+ import { randomUUID } from "node:crypto";
16
17
  import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
17
18
  import { join, dirname } from "node:path";
18
19
  import { expandHome, sanitizeId } from "../shared/paths.js";
19
20
  import { augmentedPath } from "../shared/exec-path.js";
20
21
  import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
21
- import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
22
+ import { activeBackends, backendForPath, primaryBackend, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
22
23
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
23
24
  import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
24
25
  import { isAutoWindowActive } from "./session-cache.js";
25
26
  import { noticeSuffixFor } from "./notices.js";
26
27
  import { dangerOf } from "./danger.js";
27
- import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
28
- import { selectModel } from "./model-select.js";
28
+ import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
29
+ import { selectEffort, selectModel } from "./model-select.js";
30
+ import { parseEffort } from "../shared/effort.js";
29
31
  import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince, watchRegistry } from "./cc-session.js";
30
32
  import { waitForIdle } from "./graph.js";
31
33
  import { wizardStore } from "./wizard.js";
32
34
  import { startSubagentWatch } from "./subagent-tail.js";
33
- import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
34
- import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS } from "../shared/session-label.js";
35
+ import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
36
+ import { errText } from "./last-response.js";
37
+ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
35
38
  import { splitMarkdown } from "../shared/md-chunk.js";
36
39
  import { randomTip } from "./tips.js";
37
40
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
38
- import { stripAnsi, paneIsBusy, transcriptStalled, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, talkRounds, openToolUses, renderDialog } from "./peers.js";
41
+ import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
39
42
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
40
43
  // PATH augmentation: launchd / systemd start the daemon
41
44
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
@@ -149,6 +152,9 @@ const findJsonlBySid = (sid, only) => {
149
152
  // codebuddy persists message/function_call records instead, so route every
150
153
  // parsed line through the owning backend's adapter first (identity for claude).
151
154
  const normalizeForPath = (path, raw) => backendForPath(path).normalizeTranscriptLine(raw);
155
+ // /model、/effort 的本地回执 (spawn 兜底路径 / set_model 敲出来的) 是守护进程自己的
156
+ // 操作痕迹, 不是聊天内容 —— 不镜像、不进详情页。
157
+ const MODEL_RECEIPT_RE = /^(?:Set model to|Kept model as|Set effort level to|Kept effort|Switch model)\b/;
152
158
  // Local-command noise on user lines: Claude marks the caveat / command-stdout
153
159
  // records isMeta; codebuddy persists them as PLAIN user messages (no isMeta
154
160
  // field at all). Predicates looking for the first REAL user line must skip
@@ -472,6 +478,10 @@ const recoverToolUse = (toolUseId, deps) => {
472
478
  }
473
479
  return undefined;
474
480
  };
481
+ const lineTs = (line) => {
482
+ const ts = Date.parse(line.timestamp ?? "");
483
+ return Number.isNaN(ts) ? undefined : ts;
484
+ };
475
485
  // Render one transcript line into tagged items. Caller decides batching.
476
486
  const renderLine = (raw, deps) => {
477
487
  let line;
@@ -499,6 +509,13 @@ const renderLine = (raw, deps) => {
499
509
  const goal = line.type === "user" ? goalStartOf(line.message?.content) : undefined;
500
510
  return goal ? [goal] : [];
501
511
  }
512
+ // 插话: 正在跑的这一轮中途吃进去的那句, CLI 只落这一行 attachment, 没有 user 行 ——
513
+ // 不报到的话, 它的印章没人认领, 这句话在详情里就不存在。
514
+ if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
515
+ const said = cleanUserText(line.attachment.prompt);
516
+ const ts = Date.parse(line.timestamp ?? "");
517
+ return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
518
+ }
502
519
  const out = [];
503
520
  if (line.type === "user") {
504
521
  const c = line.message?.content;
@@ -511,6 +528,8 @@ const renderLine = (raw, deps) => {
511
528
  const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
512
529
  if (stdoutMatch && stdoutMatch[1]) {
513
530
  const skillOutput = stdoutMatch[1].replace(/\[[0-9;]*m/g, "").trim();
531
+ if (MODEL_RECEIPT_RE.test(skillOutput))
532
+ return out;
514
533
  if (skillOutput) {
515
534
  out.push({ kind: "skill_output", body: `⚙️ ${skillOutput}` });
516
535
  }
@@ -544,12 +563,13 @@ const renderLine = (raw, deps) => {
544
563
  // cover reloads or replays; the user message itself always can.
545
564
  if (deps.isKeepalivePing?.(text))
546
565
  return [{ kind: "keepalive_start", body: text }];
566
+ // 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
547
567
  if (deps.isOwnInject(text))
548
- return []; // dedupe WeCom→CLI echo
568
+ return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
549
569
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
550
570
  // includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
551
571
  // onItem 消费完边界语义后自己丢掉。
552
- out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser });
572
+ out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser, ts: lineTs(line) });
553
573
  }
554
574
  else if (Array.isArray(c) && hasImages(c) && !c.some((b) => b?.type === "tool_result")) {
555
575
  // 带图的一句话: [text "[Image #1]…", image, …]。去掉占位符才与注入时记下的原文同形,
@@ -557,10 +577,10 @@ const renderLine = (raw, deps) => {
557
577
  const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
558
578
  const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
559
579
  if (text && !deps.isOwnInject(text)) {
560
- out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser });
580
+ out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
561
581
  }
562
582
  else {
563
- out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text });
583
+ out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
564
584
  }
565
585
  }
566
586
  else if (Array.isArray(c)) {
@@ -672,6 +692,7 @@ const renderLine = (raw, deps) => {
672
692
  const u = line.message?.usage;
673
693
  if (u) {
674
694
  const model = typeof line.message?.model === "string" ? line.message.model : undefined;
695
+ const effort = typeof line.effort === "string" && line.effort ? line.effort : undefined;
675
696
  const messageId = typeof line.message?.id === "string" ? line.message.id : undefined;
676
697
  const rawIn = u.input_tokens ?? 0;
677
698
  const cr = u.cache_read_input_tokens ?? 0;
@@ -685,6 +706,7 @@ const renderLine = (raw, deps) => {
685
706
  out.push({
686
707
  kind: "turn_usage",
687
708
  model,
709
+ effort,
688
710
  messageId,
689
711
  usage: {
690
712
  input,
@@ -849,6 +871,9 @@ export const startMirrorTail = (deps) => {
849
871
  buffer = buffer.slice(nl + 1);
850
872
  if (!line.trim())
851
873
  continue;
874
+ // 压缩边界: 对话被摘要替换, 系统提示 (宪章) 不变 —— 同 /clear 一样要补记忆。
875
+ if (deps.onCompact && line.includes('"compact_boundary"'))
876
+ deps.onCompact();
852
877
  for (const item of renderLine(line, deps))
853
878
  deps.onItem(item);
854
879
  }
@@ -1412,6 +1437,8 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1412
1437
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1413
1438
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1414
1439
  // 每次补之前再问一遍证人, 已提交就不补。
1440
+ /** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
1441
+ let pasteSeq = 0;
1415
1442
  const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1416
1443
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1417
1444
  // started, transcript still loading): extended timings — bracketed-paste
@@ -1452,10 +1479,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1452
1479
  const loadAndPaste = async () => {
1453
1480
  // stdin variant of the shared exec path — it carries the same hard timeout,
1454
1481
  // which a hand-rolled spawn here did not.
1455
- const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
1482
+ // 每次注入用自己的具名 buffer: 不同 pane 的注入并发时, 共用 tmux 的默认 buffer 会让
1483
+ // 一方 load 之后、paste 之前被另一方覆盖 —— 两段文本互换 pane (实测: 同一毫秒投的两份
1484
+ // 回执, 发给 A 的进了 B)。同一 pane 的串行管不到跨 pane。
1485
+ const buf = `wezard-${process.pid}-${++pasteSeq}`;
1486
+ const loaded = await runTmux(["load-buffer", "-b", buf, "-"], { stdin: text });
1456
1487
  if (!loaded.ok)
1457
1488
  return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
1458
- const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
1489
+ const pasted = await runTmux(["paste-buffer", "-b", buf, "-p", "-d", "-t", target]);
1459
1490
  if (!pasted.ok)
1460
1491
  return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1461
1492
  return { ok: true };
@@ -1751,15 +1782,12 @@ export const startMirror = (deps) => {
1751
1782
  return true;
1752
1783
  };
1753
1784
  // Keepalive ping detector for the tail's user-line judgment — the SAME
1754
- // normalized-prefix signature set keepaliveTick feeds keepaliveStamps
1755
- // (every configured form + the bare "ping" legacy streak form), so
1756
- // renderLine, the tick and the transcript parser all agree on what counts
1757
- // as a ping. Content is the source of truth for the swallow: timers and
1758
- // echo-TTLs lose it across reloads/replays.
1759
- const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing);
1760
- // The warmer alone — what the peer readers (peek / wait / roster summary) strip.
1761
- // `resumePing` is excluded on purpose: the turn it opens is real work resumed.
1762
- const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1785
+ // normalized-prefix signature set keepaliveTick feeds keepaliveStamps (the
1786
+ // configured ping + the bare "ping" legacy streak form), so renderLine, the
1787
+ // tick, the peer readers (peek / wait / roster summary) and the transcript
1788
+ // parser all agree on what counts as a ping. Content is the source of truth
1789
+ // for the swallow: timers and echo-TTLs lose it across reloads/replays.
1790
+ const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1763
1791
  const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
1764
1792
  // ── Typewriter stream lifecycle ────────────────────────────────────
1765
1793
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
@@ -1932,16 +1960,18 @@ export const startMirror = (deps) => {
1932
1960
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
1933
1961
  return;
1934
1962
  const chatId = replyChatId(a);
1963
+ const turnId = a.briefTurnId; // 推送是排队异步的, 到发出时 turn 早已收口
1935
1964
  const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1936
1965
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1937
1966
  a.standalonePending = a.standalonePending
1938
1967
  .then(async () => {
1939
1968
  for (const c of chunks) {
1940
1969
  try {
1941
- await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
1970
+ const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
1971
+ log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
1942
1972
  }
1943
1973
  catch (e) {
1944
- log.warn({ sessionId: a.sessionId, err: e.message }, "standalone push failed");
1974
+ log.warn({ sessionId: a.sessionId, chatId, err: errText(e) }, "standalone push failed");
1945
1975
  }
1946
1976
  }
1947
1977
  })
@@ -1952,13 +1982,15 @@ export const startMirror = (deps) => {
1952
1982
  if (!hasVisibleBody(content))
1953
1983
  return;
1954
1984
  const chatId = replyChatId(a);
1985
+ const turnId = a.briefTurnId;
1955
1986
  a.standalonePending = a.standalonePending
1956
1987
  .then(async () => {
1957
1988
  try {
1958
1989
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
1990
+ log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
1959
1991
  }
1960
1992
  catch (e) {
1961
- log.warn({ sessionId: a.sessionId, err: e.message }, "raw push failed");
1993
+ log.warn({ sessionId: a.sessionId, chatId, err: errText(e) }, "raw push failed");
1962
1994
  }
1963
1995
  })
1964
1996
  .catch(() => undefined);
@@ -2350,8 +2382,27 @@ export const startMirror = (deps) => {
2350
2382
  };
2351
2383
  /** 公开派活的那个同伴 —— 回复的头要标给它 (见 AttachState.replyTo)。 */
2352
2384
  const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
2385
+ /** reload 后 tail 从 EOF 接上: 正在跑的这一轮的开头那句已在身后, 它的印章随旧进程没了。
2386
+ * 那句话自己挂着信封 (谁说的、私聊还是哪个群) —— 读回来就是这一轮的出处。没挂信封 = undefined。 */
2387
+ const openingOf = (a) => {
2388
+ const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
2389
+ const env = t?.env;
2390
+ if (!env)
2391
+ return undefined;
2392
+ const home = baseOfKey(a.target);
2393
+ const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}) }
2394
+ : env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
2395
+ : undefined;
2396
+ return {
2397
+ channel: env.private ? "" : (env.chat && chatBaseOf(cfg, env.chat)) || home,
2398
+ speaker: env.kind === "human" ? `user:${env.from}` : undefined,
2399
+ from,
2400
+ query: t.text,
2401
+ };
2402
+ };
2353
2403
  /** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
2354
- * home, 其余续写 (收口后的补写) 沿用上一轮。`from` = 本轮的出处, 答给谁跟着频道走。 */
2404
+ * home, 其余续写 (收口后的补写) 沿用上一轮。`from` = 本轮的出处, 答给谁跟着频道走。
2405
+ * 返回 reload 后从 transcript 读回的开头那句 (见 openingOf), 没读到 = undefined。 */
2355
2406
  const consumeChannel = (a, reset, from) => {
2356
2407
  const p = a.pendingChannel;
2357
2408
  a.pendingChannel = undefined;
@@ -2374,15 +2425,27 @@ export const startMirror = (deps) => {
2374
2425
  }
2375
2426
  else if (!seeded) {
2376
2427
  // reload 后的第一轮续写: 内存里的频道随进程没了, 缺省 = home 会把一段私聊的
2377
- // 后续记成群里公开说的 (rolepage 上私聊窗口从此不再更新)。接回最近一轮的印章。
2378
- const last = lastChannelOf(a.target);
2428
+ // 后续记成群里公开说的 (rolepage 上私聊窗口从此不再更新), 私聊的终句还会推进群。
2429
+ // 先信这一轮开头那句自带的信封; 没有 (人在 home / CLI 里说的) 才接回最近一轮的印章 ——
2430
+ // 刚出生的分身没有「最近一轮」, 只能靠信封。
2431
+ const opening = openingOf(a);
2432
+ const last = opening ?? lastChannelOf(a.target);
2379
2433
  if (last) {
2380
2434
  a.channel = last.channel === baseOfKey(a.target) ? undefined : last.channel;
2381
2435
  a.speaker = last.speaker;
2382
2436
  a.replyTo = publicPeer(last.from);
2383
2437
  a.convFrom = last.from;
2384
2438
  }
2439
+ return opening?.query;
2440
+ }
2441
+ // 私聊派来的活 (不是回执) 这一轮必是私聊, 不论印章带没带频道: 进群的只能是人问的事的
2442
+ // 交代与显式公开。带 task 的 fork 分身就只有出处、没有频道印章, 退回 home 就把私聊派的活答进了群。
2443
+ if (from?.kind === "peer" && !from.public && !from.receipt) {
2444
+ a.channel = "";
2445
+ a.speaker = undefined;
2446
+ a.replyTo = undefined;
2385
2447
  }
2448
+ return undefined;
2386
2449
  };
2387
2450
  /** 本轮落 turn 记录的频道字段。 */
2388
2451
  const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
@@ -2395,14 +2458,103 @@ export const startMirror = (deps) => {
2395
2458
  // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2396
2459
  // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2397
2460
  const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
2398
- const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
2399
- const turnId = newTurnId();
2461
+ // ── 印章 (见 Seal) ──────────────────────────────────────────────
2462
+ // 盖在行上而不是格子里: 注入时记下, 行在 transcript 里露面时认领。比回显去重的 60s 活得久 ——
2463
+ // 排在长轮后面的那一行露面时早过了 60s, 认不出就会被当成人在 CLI 里敲的。
2464
+ const SEAL_TTL_MS = 2 * 3600_000;
2465
+ const sealKeys = (text) => {
2466
+ const t = cleanUserText(text);
2467
+ if (!t.startsWith("/"))
2468
+ return t ? [t] : [];
2469
+ const head = t.split(/\s+/, 1)[0] ?? t;
2470
+ const args = t.slice(head.length).trim();
2471
+ return [t, `\`${head}${args ? ` ${args}` : ""}\``];
2472
+ };
2473
+ const pushSeal = (a, s) => {
2474
+ const now = Date.now();
2475
+ a.seals = [...(a.seals ?? []).filter((x) => now - x.at <= SEAL_TTL_MS), s];
2476
+ };
2477
+ /** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
2478
+ * 露过面, 出处取第一枚。认不出 = undefined。 */
2479
+ const takeSeal = (a, said, pick = () => true) => {
2480
+ const t = said.trim();
2481
+ const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
2482
+ const seals = a.seals ?? [];
2483
+ const hits = seals.filter(hit);
2484
+ // CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
2485
+ // 跟着人的话进群; 没有才轮到人从聊天说的那句 (它的轮次与气泡早已建好)。
2486
+ const first = hits.find((x) => x.channel === "") ?? hits.find((x) => !!x.turn) ?? hits[0];
2487
+ if (!first)
2488
+ return undefined;
2489
+ a.seals = seals.filter((x) => !hit(x));
2490
+ hits.filter((x) => x !== first && x.turn && !x.activated).forEach((x) => mergedAway(a, x.turn));
2491
+ return first;
2492
+ };
2493
+ /** 人那句被并进了一轮私聊: 它预建的气泡与轮次收口成一个链接 —— 答复不进群, 也不让气泡挂到超时。 */
2494
+ const mergedAway = (a, q) => {
2495
+ void finishBubble(a, q.bubble, `${briefDetailLink(q.turnId, a.target)} (这句和一段私聊并成了一轮, 答复只在 rolepage)`, true);
2496
+ recordTurnClose(q.turnId);
2497
+ };
2498
+ /** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
2499
+ * `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
2500
+ const activateDispatch = (a, s, at = s.at) => {
2501
+ const q = s.turn;
2502
+ s.activated = true;
2400
2503
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2401
2504
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2402
2505
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2403
- const from = consumeFrom(a);
2404
- consumeChannel(a, true, from);
2405
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from, ...channelFields(a) });
2506
+ // 人说的这一轮没有同伴出处: 别的行认领了、还没开出轮的印章被这一句接管, 一并作废。
2507
+ a.pendingFrom = undefined;
2508
+ a.pendingOrigin = undefined;
2509
+ a.pendingBriefQuery = undefined;
2510
+ a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
2511
+ consumeChannel(a, true, undefined);
2512
+ recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
2513
+ if (a.briefTurnId)
2514
+ closeBriefTurn(a);
2515
+ openBriefTurn(a, q);
2516
+ };
2517
+ /** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
2518
+ const injectInto = (turnId, a, s, ts) => recordTurnInject(turnId, { ts, body: s.query.trim(), ...(s.from ? { from: s.from } : {}), channel: s.channel ?? baseOfKey(a.target), ...(s.speaker ? { speaker: s.speaker } : {}) });
2519
+ /** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
2520
+ * 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
2521
+ * 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
2522
+ const interject = (a, said, ts) => {
2523
+ if (!a.briefTurnId)
2524
+ return;
2525
+ const s = takeSeal(a, said, (x) => !x.turn);
2526
+ if (s)
2527
+ injectInto(a.briefTurnId, a, s, ts);
2528
+ };
2529
+ /** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
2530
+ const bindLine = (a, said, ts) => {
2531
+ const s = takeSeal(a, said);
2532
+ if (!s)
2533
+ return undefined;
2534
+ if (s.turn) {
2535
+ if (!s.activated)
2536
+ activateDispatch(a, s, ts);
2537
+ return s;
2538
+ }
2539
+ // 忙时贴进去、正在跑的那一轮还没出终句: 它是插进那一轮的话 (priority now), 那一轮的
2540
+ // 回复仍归原来那一方, 这句记成那一轮的一条插话。闲时贴进去的、或上一轮已经说完的, 才是新一轮的开头。
2541
+ if (a.briefTurnId && !a.briefConcluded && !s.fresh) {
2542
+ injectInto(a.briefTurnId, a, s, Date.now());
2543
+ return s;
2544
+ }
2545
+ if (a.briefTurnId)
2546
+ closeBriefTurn(a);
2547
+ const now = Date.now();
2548
+ a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
2549
+ a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
2550
+ // 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
2551
+ a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
2552
+ a.pendingBriefQuery = s.query;
2553
+ a.pendingBriefAt = ts;
2554
+ return s;
2555
+ };
2556
+ const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
2557
+ const turnId = newTurnId();
2406
2558
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2407
2559
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2408
2560
  const q = { turnId, bubble, isSlash };
@@ -2413,15 +2565,23 @@ export const startMirror = (deps) => {
2413
2565
  const live = a.briefBubble === bubble && !a.briefConcluded;
2414
2566
  void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
2415
2567
  }, HARD_TIMEOUT_MS);
2568
+ const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
2569
+ // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
2570
+ // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
2571
+ // 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
2572
+ const queued = !isSlash && idleVerdict(a.target) === false;
2573
+ pushSeal(a, seal);
2574
+ if (queued)
2575
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
2576
+ else
2577
+ activateDispatch(a, seal);
2416
2578
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
2417
2579
  // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
2418
2580
  // 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
2419
2581
  // 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
2420
2582
  // 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
2421
- // replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。
2422
- if (a.briefTurnId)
2423
- closeBriefTurn(a);
2424
- openBriefTurn(a, q);
2583
+ // replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
2584
+ // 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
2425
2585
  // ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
2426
2586
  // turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
2427
2587
  // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
@@ -2433,6 +2593,35 @@ export const startMirror = (deps) => {
2433
2593
  log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
2434
2594
  }
2435
2595
  };
2596
+ /** reload 后的第一笔产出: 那一轮多半还是旧进程开的那条记录 (内存里的 briefTurnId 随进程没了) ——
2597
+ * 另开一轮就把一件活切成几段、每段都带着开头那句。只在 transcript 最后那句问话正是它的问话时接回,
2598
+ * 频道与出处取它自己记下的; 对不上 (旧进程收口前就有了新问话) 照常开新的。 */
2599
+ const resumeOpenTurn = (a) => {
2600
+ if (a.channelSeeded)
2601
+ return false;
2602
+ const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
2603
+ const said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
2604
+ const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
2605
+ if (!r)
2606
+ return false;
2607
+ a.channelSeeded = true;
2608
+ a.pendingFrom = undefined;
2609
+ a.pendingOrigin = undefined;
2610
+ a.pendingChannel = undefined;
2611
+ a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
2612
+ a.speaker = r.speaker;
2613
+ a.convFrom = r.from;
2614
+ a.replyTo = publicPeer(r.from);
2615
+ a.turnFromChat = a.turnFromChat ?? true;
2616
+ a.briefTurnId = r.id;
2617
+ a.briefBubble = undefined;
2618
+ a.briefIsSlash = false;
2619
+ a.briefHadTool = r.items.some((it) => it.t === "tool_use");
2620
+ a.briefConcluded = false;
2621
+ a.briefLastText = undefined;
2622
+ log.info({ sessionId: a.sessionId, turnId: r.id }, "brief: turn resumed after reload");
2623
+ return true;
2624
+ };
2436
2625
  // 无气泡 turn。CLI 侧自己开的一轮 (WeCom 从没发过消息, 拿不到 frame/streamId), 以及
2437
2626
  // 收口后仍有 item 补写进来的情况, 都要有一个 turn 承接 —— 否则每条 tool/text 都掉进
2438
2627
  // fallback standalone, 一轮工具密集的对话能在群里刷出几十条散装气泡。这里只推一条
@@ -2440,18 +2629,22 @@ export const startMirror = (deps) => {
2440
2629
  const ensureBriefTurn = (a) => {
2441
2630
  if (a.briefTurnId)
2442
2631
  return;
2443
- const turnId = newTurnId();
2444
2632
  const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
2633
+ const at = a.pendingBriefAt;
2445
2634
  a.pendingBriefQuery = undefined;
2635
+ a.pendingBriefAt = undefined;
2636
+ if (!query && a.pendingFromCli !== true && resumeOpenTurn(a))
2637
+ return;
2638
+ const turnId = newTurnId();
2446
2639
  // 出处继承: 有真人 CLI 输入行开头 → CLI 轮; 否则 (收口后的补写、peer/graph 注入
2447
2640
  // 开出的轮) 沿用上一轮 —— 那些续写属于同一场对话, 不该改变可见性。
2448
2641
  const fromCli = a.pendingFromCli === true;
2449
2642
  a.pendingFromCli = false;
2450
2643
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2451
2644
  const fresh = consumeFrom(a);
2452
- consumeChannel(a, fromCli, fresh);
2645
+ const opened = consumeChannel(a, fromCli, fresh);
2453
2646
  const from = fresh ?? a.convFrom;
2454
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from, ...channelFields(a) });
2647
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
2455
2648
  a.briefTurnId = turnId;
2456
2649
  a.briefBubble = undefined;
2457
2650
  a.briefIsSlash = false;
@@ -2781,7 +2974,7 @@ export const startMirror = (deps) => {
2781
2974
  return;
2782
2975
  }
2783
2976
  if (item.kind === "turn_usage") {
2784
- recordTurnUsage(turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
2977
+ recordTurnUsage(turnId, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
2785
2978
  return;
2786
2979
  }
2787
2980
  if (item.kind === "skill_output") {
@@ -2983,6 +3176,10 @@ export const startMirror = (deps) => {
2983
3176
  // (crashed reply). Release BEFORE the swallow gate so this real line (and
2984
3177
  // its reply) mirror normally instead of being eaten by the stale swallow.
2985
3178
  if (item.kind === "user_text") {
3179
+ // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3180
+ // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
3181
+ if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
3182
+ return;
2986
3183
  endKeepaliveSwallow(a);
2987
3184
  // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
2988
3185
  // 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
@@ -2992,13 +3189,24 @@ export const startMirror = (deps) => {
2992
3189
  // quiet (includeUser=false, 默认) 同样记: includeUser 只决定气泡渲不渲染,
2993
3190
  // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
2994
3191
  a.pendingBriefQuery = item.body;
3192
+ a.pendingBriefAt = item.ts;
2995
3193
  a.pendingFromCli = true;
2996
3194
  }
3195
+ if (item.kind === "user_inject") {
3196
+ interject(a, item.said, item.ts);
3197
+ return;
3198
+ }
2997
3199
  // 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
2998
3200
  // 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
2999
3201
  if (item.kind === "user_query") {
3000
- if (a.pendingBriefQuery !== undefined || !a.briefTurnId)
3202
+ // 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
3203
+ const sealed = bindLine(a, item.said, item.ts);
3204
+ if (sealed && !sealed.turn)
3205
+ return;
3206
+ if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
3001
3207
  a.pendingBriefQuery = item.body;
3208
+ a.pendingBriefAt = item.ts;
3209
+ }
3002
3210
  // 只认同一句话: 轮次中途 CLI 里又粘了一张图, 不能改写正在跑的这一轮的问话。
3003
3211
  else
3004
3212
  recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
@@ -3032,7 +3240,7 @@ export const startMirror = (deps) => {
3032
3240
  recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
3033
3241
  }
3034
3242
  else if (item.kind === "turn_usage") {
3035
- recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
3243
+ recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
3036
3244
  }
3037
3245
  else if (item.kind === "turn_end") {
3038
3246
  recordTurnClose(id);
@@ -3375,7 +3583,7 @@ export const startMirror = (deps) => {
3375
3583
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
3376
3584
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
3377
3585
  : "";
3378
- const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
3586
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd, keepaliveDisabled }) => {
3379
3587
  const target = resolveTarget(targetOverride);
3380
3588
  if (!target)
3381
3589
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -3405,6 +3613,7 @@ export const startMirror = (deps) => {
3405
3613
  // model it asked for (possibly "" = CLI default) and says so; a re-attach
3406
3614
  // onto an already-running pane doesn't, so it keeps what was recorded.
3407
3615
  const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
3616
+ const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
3408
3617
  // Same carry rule again: an explicit spawn-time choice wins, otherwise this
3409
3618
  // wizard's existing policy (in-memory, or on disk across a reload) sticks.
3410
3619
  const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
@@ -3423,6 +3632,7 @@ export const startMirror = (deps) => {
3423
3632
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
3424
3633
  pendingCwd: carryPending,
3425
3634
  model: carryModel,
3635
+ effort: carryEffort,
3426
3636
  keepaliveDisabled: carryKeepaliveDisabled,
3427
3637
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
3428
3638
  standalonePending: Promise.resolve(),
@@ -3440,6 +3650,7 @@ export const startMirror = (deps) => {
3440
3650
  isOwnAssistantSend,
3441
3651
  isKeepalivePing,
3442
3652
  onItem: (item) => onItem(a, item),
3653
+ onCompact: () => contextCut(a.target, "compact"),
3443
3654
  detailUrlFor,
3444
3655
  sessionId,
3445
3656
  target,
@@ -3461,6 +3672,7 @@ export const startMirror = (deps) => {
3461
3672
  tmuxPane: a.tmuxPane || undefined,
3462
3673
  cwd: a.runningCwd || undefined,
3463
3674
  model: a.model || undefined,
3675
+ effort: a.effort || undefined,
3464
3676
  pendingCwd: a.pendingCwd || undefined,
3465
3677
  cwdConfirmed: prevRec?.cwdConfirmed,
3466
3678
  keepaliveOff: prevRec?.keepaliveOff,
@@ -3498,8 +3710,12 @@ export const startMirror = (deps) => {
3498
3710
  // it collapses N distinct peers onto one sessionId (attach() then dedupes by
3499
3711
  // sid and silently detaches all-but-one). Excluding claimed sids keeps each
3500
3712
  // peer on its own session.
3713
+ // 正在出生、还没 attach 的会话 sid: 新会话 (spawn 时 jsonl 就预建了) 与认下了的分叉
3714
+ // 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
3715
+ // jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
3716
+ const inFlightSids = new Set();
3501
3717
  const sidsClaimedByOthers = (principal) => {
3502
- const s = new Set();
3718
+ const s = new Set(inFlightSids);
3503
3719
  for (const [k, v] of Object.entries(deps.store.all()))
3504
3720
  if (k !== principal && v.sessionId)
3505
3721
  s.add(v.sessionId);
@@ -3609,6 +3825,7 @@ export const startMirror = (deps) => {
3609
3825
  tmuxSession: rec.tmuxSession ?? "",
3610
3826
  cwd: rec.cwd,
3611
3827
  model: rec.model,
3828
+ effort: rec.effort,
3612
3829
  pendingCwd: rec.pendingCwd,
3613
3830
  });
3614
3831
  if (!r.ok) {
@@ -3809,6 +4026,17 @@ export const startMirror = (deps) => {
3809
4026
  catch { /* unreadable */ }
3810
4027
  return undefined;
3811
4028
  };
4029
+ // A resume / --fork-session copy is seeded with the origin's history, uuids
4030
+ // intact — so "its first user line is ours" is pane-certain evidence that a
4031
+ // new jsonl is OUR fork. "Has any user content" is not: a sibling spawned in
4032
+ // the same cwd a moment earlier also has some, and its sid isn't claimed yet
4033
+ // (attach lands after the spawn) — that is how a respawned .evolve got bound
4034
+ // to a haiku sibling's fresh transcript and read empty from then on.
4035
+ const forkOf = (origin) => {
4036
+ const seed = firstUserUuid(origin);
4037
+ // origin 还没有 user 行 (没说过话就死了 / 刚开的父亲): 没有可比的种子, 退回旧判据。
4038
+ return seed === undefined ? (path) => firstUserUuid(path) !== undefined : (path) => firstUserUuid(path) === seed;
4039
+ };
3812
4040
  // startOffset semantics differ by caller: /clear passes 0 to replay the
3813
4041
  // freshly-rotated jsonl (only holds the /clear line + new content); resume
3814
4042
  // migration omits it (→ tail from EOF) because the fork file is seeded with
@@ -3829,6 +4057,8 @@ export const startMirror = (deps) => {
3829
4057
  // drift 上下文是延续的, 画中性的 "switch"。
3830
4058
  const cleared = jsonlIsPostClearChild(newJsonlPath);
3831
4059
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4060
+ if (cleared)
4061
+ contextCut(a.target, "clear");
3832
4062
  if (cleared) {
3833
4063
  // 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
3834
4064
  // dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
@@ -3855,6 +4085,7 @@ export const startMirror = (deps) => {
3855
4085
  isOwnAssistantSend,
3856
4086
  isKeepalivePing,
3857
4087
  onItem: (item) => onItem(a, item),
4088
+ onCompact: () => contextCut(a.target, "compact"),
3858
4089
  detailUrlFor,
3859
4090
  sessionId: newSessionId,
3860
4091
  target: a.target,
@@ -4057,6 +4288,9 @@ export const startMirror = (deps) => {
4057
4288
  // Post-/clear: only transcripts that didn't exist before the rotation
4058
4289
  // can be ours, which is what licenses the shorter fingerprint above.
4059
4290
  .filter((n) => !pendingClear || !pendingClear.baseline.has(n))
4291
+ // 别人的 (含还在出生、没 attach 的新会话与分叉 —— 分叉文件带着父亲注进去的原话,
4292
+ // 指纹会撞上) 不认。
4293
+ .filter((n) => !sidsClaimedByOthers(a.target).has(n.replace(/\.jsonl$/, "")))
4060
4294
  .map((n) => ({ n, m: (() => { try {
4061
4295
  return statSync(join(dir, n)).mtimeMs;
4062
4296
  }
@@ -4199,6 +4433,17 @@ export const startMirror = (deps) => {
4199
4433
  // 新 pane 落地后的钩子 —— pane 上限在这里数 (由 index.ts 装上; 没装 = 不数)。
4200
4434
  let afterSpawn = () => { };
4201
4435
  const onSpawn = (fn) => { afterSpawn = fn; };
4436
+ let afterCut = () => { };
4437
+ const onContextCut = (fn) => { afterCut = fn; };
4438
+ const contextCut = (target, cut) => {
4439
+ log.info({ target, cut }, "context cut");
4440
+ try {
4441
+ afterCut(target, cut);
4442
+ }
4443
+ catch (e) {
4444
+ log.warn({ target, cut, err: e.message }, "onContextCut hook threw");
4445
+ }
4446
+ };
4202
4447
  // `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
4203
4448
  // 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
4204
4449
  // 旧工作区里。
@@ -4210,6 +4455,32 @@ export const startMirror = (deps) => {
4210
4455
  return undefined;
4211
4456
  }
4212
4457
  };
4458
+ // 确切模型名的证据 (见 exactModelId): transcript 里 API 真答过的 model id, 只认同一个
4459
+ // CLI 的。先问最可能的那几个会话 (自己、被克隆者), 再按新近扫其余 —— 惰性的,
4460
+ // 第一个对上就停, 绝大多数时候只读一两个 transcript 的尾巴。
4461
+ function* modelSightings(cli, likely) {
4462
+ const name = cli ?? primaryBackend().name;
4463
+ const pathOf = (t) => byTarget.get(t)?.jsonlPath ?? expandHome(deps.store.get(t)?.jsonlPath ?? "");
4464
+ const head = likely.map(pathOf).filter(Boolean);
4465
+ // 生成器体到第一次取值才跑: 前几个就对上时, 其余几百个 stat 一个也不做。
4466
+ const paths = function* () {
4467
+ yield* head;
4468
+ yield* Object.values(deps.store.all())
4469
+ .map((r) => expandHome(r.jsonlPath))
4470
+ .filter((p) => !head.includes(p))
4471
+ .map((p) => ({ p, m: mtimeOf(p) }))
4472
+ .filter((x) => x.m > 0)
4473
+ .sort((x, y) => y.m - x.m)
4474
+ .map((x) => x.p);
4475
+ };
4476
+ for (const path of paths()) {
4477
+ if (!existsSync(path) || backendForPath(path).name !== name)
4478
+ continue;
4479
+ const id = lastModel(path);
4480
+ if (id)
4481
+ yield id;
4482
+ }
4483
+ }
4213
4484
  const newSession = async (target, windowName, cli, opts) => {
4214
4485
  const prev = byTarget.get(target);
4215
4486
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
@@ -4241,23 +4512,36 @@ export const startMirror = (deps) => {
4241
4512
  const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
4242
4513
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
4243
4514
  const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
4515
+ // 群的默认会话 (管家) 没点名模型 / 档位时用 models.router。只管这里新起的 —— 死 pane
4516
+ // 重生走绑定里记下的模型, 不经过这里; 已在跑的管家也不动 (换模型会让缓存整份重读)。
4517
+ const router = !tagOfKey(target) && !isInternalKey(target) ? cfg.models.router : undefined;
4518
+ const effModel = opts?.model?.trim() || router?.model || undefined;
4519
+ const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort) ?? router?.effort;
4244
4520
  if (prev?.tmuxPane) {
4245
4521
  // Best-effort kill; ignore errors (pane may already be dead).
4246
4522
  void runTmux(["kill-pane", "-t", prev.tmuxPane]);
4247
4523
  }
4248
4524
  if (prev)
4249
4525
  detach(prev, "/new respawn");
4526
+ // sid 由这里先定, 出生到 attach 之间挂在 inFlightSids 上 (见那里)。
4527
+ const freshSid = randomUUID();
4528
+ inFlightSids.add(freshSid);
4250
4529
  const r = await spawnTmuxClaude({
4251
4530
  cfg,
4252
4531
  log: log.child({ sub: "new-session", target }),
4532
+ sessionId: freshSid,
4253
4533
  windowName: windowName ?? target,
4254
4534
  cwdOverride: eff,
4255
4535
  cli: effCli,
4256
- model: opts?.model,
4536
+ model: effModel,
4537
+ knownModels: modelSightings(effCli, [target, base]),
4538
+ effort: effEffort,
4257
4539
  systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
4258
- });
4259
- if (!r.ok)
4540
+ }).catch((e) => { inFlightSids.delete(freshSid); throw e; });
4541
+ if (!r.ok) {
4542
+ inFlightSids.delete(freshSid);
4260
4543
  return { ok: false, reason: r.reason };
4544
+ }
4261
4545
  const att = attach({
4262
4546
  sessionId: r.sessionId,
4263
4547
  jsonlPath: r.jsonlPath,
@@ -4271,9 +4555,11 @@ export const startMirror = (deps) => {
4271
4555
  // means this CLI's default, not the dead pane's model. Store what
4272
4556
  // spawnTmuxClaude actually confirmed via `/model` (r.model), not the
4273
4557
  // raw request — a later respawn's fast path then matches on try one.
4274
- model: r.model ?? (opts?.model?.trim() ?? ""),
4558
+ model: r.model ?? effModel ?? "",
4559
+ effort: r.effort ?? "",
4275
4560
  keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
4276
4561
  });
4562
+ inFlightSids.delete(freshSid);
4277
4563
  if (!att.ok)
4278
4564
  return { ok: false, reason: att.reason };
4279
4565
  // 首条注入吃冷时序(injectText 走 /mirror/spawn 时已经硬编码 freshSpawn:true,
@@ -4307,6 +4593,8 @@ export const startMirror = (deps) => {
4307
4593
  tmuxSession: baseA.tmuxSession || undefined,
4308
4594
  tmuxPane: baseA.tmuxPane || undefined,
4309
4595
  cwd: baseA.runningCwd || undefined,
4596
+ model: baseA.model || undefined,
4597
+ effort: baseA.effort || undefined,
4310
4598
  cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
4311
4599
  pendingCwd: undefined,
4312
4600
  });
@@ -4320,7 +4608,7 @@ export const startMirror = (deps) => {
4320
4608
  if (!opts?.silent)
4321
4609
  pushProjectInfo(target, "created");
4322
4610
  afterSpawn();
4323
- return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
4611
+ return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
4324
4612
  };
4325
4613
  // ── Clone ───────────────────────────────────────────────────────────
4326
4614
  // 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
@@ -4337,19 +4625,35 @@ export const startMirror = (deps) => {
4337
4625
  // (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
4338
4626
  const FORK_POLL_MS = 400;
4339
4627
  const FORK_WAIT_MS = 45_000;
4340
- const awaitFork = async (projectDir, baseline, claimed) => {
4341
- const deadline = Date.now() + FORK_WAIT_MS;
4628
+ // 认下的分叉文件同样记进 inFlightSids: 同一个父亲并发克隆出几个分身时, 每个分叉的首行
4629
+ // 都与父亲相同, 不预留的话两个 awaitFork 会认下同一个文件。
4630
+ const awaitFork = async (projectDir, baseline, claimed, origin, probe) => {
4631
+ const ours = forkOf(origin);
4632
+ const take = (sessionId, jsonlPath) => (inFlightSids.add(sessionId), { sessionId, jsonlPath });
4633
+ const start = Date.now();
4634
+ const deadline = start + FORK_WAIT_MS;
4342
4635
  while (Date.now() < deadline) {
4636
+ // 首选会话注册表: 它按 pane 报这个进程此刻的 sid —— 并发分叉时唯一不靠猜的来源。
4637
+ const live = probe.pane ? sessionOnPane(probe.homeDir, probe.pane) : undefined;
4638
+ if (live && live.sessionId !== probe.parentSid && !claimed.has(live.sessionId)) {
4639
+ const path = join(projectDir, `${live.sessionId}.jsonl`);
4640
+ if (existsSync(path) && ours(path))
4641
+ return take(live.sessionId, path);
4642
+ }
4343
4643
  const born = [...listJsonls(projectDir)]
4344
- .filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
4644
+ .filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")) && !inFlightSids.has(n.replace(/\.jsonl$/, "")))
4345
4645
  .map((n) => ({ n, path: join(projectDir, n) }))
4346
4646
  // 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
4347
- // 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读。
4348
- .filter((c) => firstUserUuid(c.path) !== undefined)
4647
+ // 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读; 别的 pane
4648
+ // 同时新开的会话也有 user 行, 只认首行 uuid 与父亲相同的那个。
4649
+ .filter((c) => ours(c.path))
4349
4650
  .sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
4350
- const hit = born[0];
4651
+ // 注册表报不出 (没有注册表的后端 / 这个 pane 没登记 / 一阵子了还报着父亲的 sid)
4652
+ // 才按「新出现、首行同父」去猜, 认下即预留。
4653
+ const guess = !hasRegistry(probe.homeDir) || !live || Date.now() - start > FORK_WAIT_MS / 3;
4654
+ const hit = guess ? born[0] : undefined;
4351
4655
  if (hit)
4352
- return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
4656
+ return take(hit.n.replace(/\.jsonl$/, ""), hit.path);
4353
4657
  await sleep(FORK_POLL_MS);
4354
4658
  }
4355
4659
  return undefined;
@@ -4362,7 +4666,7 @@ export const startMirror = (deps) => {
4362
4666
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4363
4667
  if (!canInherit) {
4364
4668
  const r = await newSession(args.target, args.windowName, args.cli, {
4365
- model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
4669
+ model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
4366
4670
  });
4367
4671
  return { ...r, inherited: false };
4368
4672
  }
@@ -4370,6 +4674,10 @@ export const startMirror = (deps) => {
4370
4674
  const projectDir = dirname(parent.jsonlPath);
4371
4675
  const baseline = listJsonls(projectDir);
4372
4676
  const lg = log.child({ sub: "clone", target: args.target });
4677
+ // 分身默认跑被克隆者此刻真在跑的那个: 记下的模型, 没记 (CLI 默认) 就取它 transcript 里的 id。
4678
+ const parentCli = backendForPath(parent.jsonlPath).name;
4679
+ const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
4680
+ const effort = args.effort ?? parseEffort(parent.effort);
4373
4681
  const r = await spawnTmuxClaude({
4374
4682
  cfg,
4375
4683
  log: lg,
@@ -4377,15 +4685,17 @@ export const startMirror = (deps) => {
4377
4685
  forkSession: true,
4378
4686
  windowName: args.windowName ?? args.target,
4379
4687
  cwdOverride: parent.runningCwd,
4380
- cli: backendForPath(parent.jsonlPath).name,
4381
- model: args.model,
4688
+ cli: parentCli,
4689
+ model,
4690
+ knownModels: modelSightings(parentCli, [args.parent]),
4691
+ effort,
4382
4692
  systemPrompt: args.systemPrompt,
4383
4693
  });
4384
4694
  if (!r.ok)
4385
4695
  return { ok: false, reason: r.reason, inherited: false };
4386
4696
  // 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
4387
4697
  // transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
4388
- // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」), 群里看到的第一条也是它。
4698
+ // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
4389
4699
  const hello = args.bootstrap?.trim()
4390
4700
  || "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
4391
4701
  rememberInject(hello);
@@ -4398,7 +4708,9 @@ export const startMirror = (deps) => {
4398
4708
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4399
4709
  return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
4400
4710
  }
4401
- const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
4711
+ const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
4712
+ homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
4713
+ });
4402
4714
  // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
4403
4715
  // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
4404
4716
  // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
@@ -4414,24 +4726,35 @@ export const startMirror = (deps) => {
4414
4726
  tmuxSession: r.tmuxSession,
4415
4727
  cwd: r.cwd,
4416
4728
  pendingCwd: "",
4417
- model: r.model ?? (args.model?.trim() ?? ""),
4729
+ model: r.model ?? model,
4730
+ effort: r.effort ?? "",
4418
4731
  keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
4419
4732
  });
4733
+ // 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
4734
+ inFlightSids.delete(fork.sessionId);
4420
4735
  if (!att.ok)
4421
4736
  return { ok: false, reason: att.reason, inherited: false };
4422
4737
  const spawned = byTarget.get(args.target);
4423
4738
  if (spawned) {
4424
4739
  // muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
4425
- // 内容 (群里第一条就该是它), 所以这里不静音。
4740
+ // 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
4426
4741
  spawned.justSpawned = true;
4427
4742
  spawned.keepaliveOff = true;
4428
4743
  spawned.keepaliveOffAt = Date.now();
4429
4744
  spawned.keepalive = undefined;
4430
4745
  persistPause(spawned);
4746
+ // 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
4747
+ // 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
4748
+ if (args.bootstrapFrom) {
4749
+ spawned.pendingFrom = { from: args.bootstrapFrom.from, at: Date.now() };
4750
+ spawned.pendingBriefQuery = args.bootstrapFrom.query;
4751
+ }
4752
+ // 开场白不是谁在群里问的: 带活的是私聊派活, 不带的是守护进程让它自报家门 —— 都只进 rolepage。
4753
+ spawned.pendingChannel = { channel: "", at: Date.now() };
4431
4754
  }
4432
4755
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4433
4756
  afterSpawn();
4434
- return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
4757
+ return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
4435
4758
  };
4436
4759
  /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
4437
4760
  const sessionInfo = (target) => {
@@ -4447,6 +4770,7 @@ export const startMirror = (deps) => {
4447
4770
  cwd: a?.runningCwd || rec?.cwd || "",
4448
4771
  cli: backendForPath(jsonlPath).name,
4449
4772
  model: a?.model || rec?.model || "",
4773
+ effort: a?.effort || rec?.effort || "",
4450
4774
  tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
4451
4775
  contextTokens: lastContextTokens(jsonlPath),
4452
4776
  };
@@ -4605,7 +4929,8 @@ export const startMirror = (deps) => {
4605
4929
  ...allTargets().map(baseOfKey),
4606
4930
  baseOfKey(self),
4607
4931
  ]);
4608
- return [...bases].filter(Boolean).sort().map((base) => ({
4932
+ // 内部会话 (记忆整理者) 的 home 不是聊天: 不列、不补名。
4933
+ return [...bases].filter((b) => b && !isInternalKey(b)).sort().map((base) => ({
4609
4934
  base,
4610
4935
  name: chatNameOf(cfg, base),
4611
4936
  self: base === baseOfKey(self),
@@ -4633,18 +4958,27 @@ export const startMirror = (deps) => {
4633
4958
  return false;
4634
4959
  if (live.status !== "idle")
4635
4960
  return undefined;
4961
+ return !hanging(target, live.sessionId);
4962
+ };
4963
+ /** 闲着却悬着工具调用 = 停在审批卡 / 本地弹窗上。resume / fork 会换 sid: 悬着的
4964
+ * 调用记在注册表报的当前那份 transcript 里。 */
4965
+ const hanging = (target, sid) => {
4636
4966
  const bound = jsonlOf(target);
4637
- // resume / fork 会换 sid: 悬着的工具调用记在注册表报的当前那份 transcript 里。
4638
- const jsonl = bound ? join(dirname(bound), `${live.sessionId}.jsonl`) : "";
4639
- return !(jsonl && existsSync(jsonl) && openToolUses(jsonl).length > 0);
4967
+ const jsonl = bound ? join(dirname(bound), `${sid}.jsonl`) : "";
4968
+ return !!jsonl && existsSync(jsonl) && openToolUses(jsonl).length > 0;
4969
+ };
4970
+ const parkedNow = (target) => {
4971
+ const pane = paneOf(target);
4972
+ const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
4973
+ return live?.status === "idle" && hanging(target, live.sessionId);
4640
4974
  };
4641
4975
  const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
4642
4976
  // 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
4643
4977
  // 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
4644
4978
  const IDLE_SAFETY_POLL_MS = 1000;
4645
- const untilIdle = (target, timeoutMs) => {
4979
+ const untilIdle = (target, timeoutMs, { aborted = () => false, confirm = 2 } = {}) => {
4646
4980
  if (idleVerdict(target) === undefined)
4647
- return waitForIdle(target, isBusy, timeoutMs, () => false, { rampMs: 0, confirm: 2 });
4981
+ return waitForIdle(target, isBusy, timeoutMs, aborted, { rampMs: 0, confirm });
4648
4982
  return new Promise((resolve) => {
4649
4983
  let done = false;
4650
4984
  const finish = (r) => {
@@ -4660,6 +4994,10 @@ export const startMirror = (deps) => {
4660
4994
  const check = async () => {
4661
4995
  if (done)
4662
4996
  return;
4997
+ if (aborted()) {
4998
+ finish({ idle: false, reason: "stopped" });
4999
+ return;
5000
+ }
4663
5001
  const v = idleVerdict(target);
4664
5002
  if (v ?? !(await isBusy(target)))
4665
5003
  finish({ idle: true });
@@ -4672,15 +5010,15 @@ export const startMirror = (deps) => {
4672
5010
  };
4673
5011
  const lastText = (target) => {
4674
5012
  const p = jsonlOf(target);
4675
- return p ? lastAssistantText(p, 4000, warmerSigs) : "";
5013
+ return p ? lastAssistantText(p, 4000, pingSigs) : "";
4676
5014
  };
4677
5015
  const replySince = (target, sinceMs = 0) => {
4678
5016
  const p = jsonlOf(target);
4679
- return p ? lastReply(p, sinceMs, warmerSigs) : "";
5017
+ return p ? lastReply(p, sinceMs, pingSigs) : "";
4680
5018
  };
4681
- const replyToPeerSince = (target, fromName, sinceMs = 0) => {
5019
+ const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
4682
5020
  const p = jsonlOf(target);
4683
- return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs) : undefined;
5021
+ return p ? replyToPeerIn(p, fromName, sinceMs, pingSigs, turn) : undefined;
4684
5022
  };
4685
5023
  /** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
4686
5024
  const paneLiveOf = async (target) => {
@@ -4747,7 +5085,7 @@ export const startMirror = (deps) => {
4747
5085
  paneAlive,
4748
5086
  busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
4749
5087
  lastActivity,
4750
- summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, warmerSigs) : "(未绑定会话)",
5088
+ summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, pingSigs) : "(未绑定会话)",
4751
5089
  self: t === self,
4752
5090
  };
4753
5091
  };
@@ -4804,24 +5142,27 @@ export const startMirror = (deps) => {
4804
5142
  return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
4805
5143
  // `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
4806
5144
  // 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
4807
- const rounds = talkRounds(jsonl, n, warmerSigs).slice(-n);
5145
+ const rounds = talkRounds(jsonl, n, pingSigs).slice(-n);
4808
5146
  const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
4809
5147
  const waiting = busy ? [] : openToolUses(jsonl);
4810
5148
  return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
4811
5149
  };
4812
5150
  // ── Prompt-cache keepalive ──────────────────────────────────────────
4813
- // Anthropic's prompt cache expires ~5min after the last request. A pane that
4814
- // goes idle (agent parked on a peer, background task running) lets the whole
4815
- // context fall out of cache — the next real turn then re-writes it at 1.25x.
4816
- // We inject a tiny ping just before expiry so the model makes one cheap
4817
- // request (cache-read 0.1x) that slides the TTL forward. The whole schedule is
4818
- // anchored to the last REAL (non-ping) activity, NOT to our own pings — so a
4819
- // dead session is never kept warm forever, and once real work is older than
4820
- // `maxIdleSec` we stop and let the cache die.
5151
+ // Anthropic's prompt cache expires a TTL (5min API default, 1h on a Claude Code
5152
+ // subscription) after the start of the last request. A pane that goes idle
5153
+ // (agent parked on a peer, background task running) lets the whole context fall
5154
+ // out of cache — the next real turn then re-writes it. We inject a tiny ping just
5155
+ // before expiry so the model makes one cheap request (cache-read) that slides the
5156
+ // TTL forward. The budget is anchored to the last REAL (non-ping) activity, NOT to
5157
+ // our own pings — so a dead session is never kept warm forever.
4821
5158
  // After `/stop`, ignore pane-busy as a resume signal for this long — long
4822
5159
  // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
4823
5160
  // so it can't self-resume the pause. A genuine new turn after this window does.
4824
5161
  const RESUME_GRACE_MS = 30_000;
5162
+ // A cache untouched this long is not kept warm: on a 1h entry the ping would come
5163
+ // ~59min into the idle stretch, re-reading the whole context to cover a gap the
5164
+ // TTL already covered — warming only pays on short (5min) entries.
5165
+ const KEEPALIVE_MAX_IDLE_MS = 10 * 60_000;
4825
5166
  // Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
4826
5167
  // from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
4827
5168
  // bump mtime without being a model turn, which would fake a long-dead session
@@ -4841,21 +5182,18 @@ export const startMirror = (deps) => {
4841
5182
  round = s.streak;
4842
5183
  }
4843
5184
  catch { /* unreadable tail */ }
4844
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0 };
5185
+ return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
4845
5186
  };
4846
- const fireKeepalive = async (a, stalled) => {
5187
+ const fireKeepalive = async (a) => {
4847
5188
  const kc = cfg.wrc.mirror.keepalive;
4848
- // Stalled (error/limit banner + idle pane): send the full resume instruction
4849
- // — inviting the model to finish genuinely-unfinished work, or to reply
4850
- // "pong" if it was legitimately parked. Otherwise the plain warmer, the SAME
4851
- // full instruction every round: a bare "ping" used to be sent from round 2 on
5189
+ // The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
4852
5190
  // (the instruction was already in context), but the model reads a bare "ping"
4853
5191
  // as an open turn and answers it however it likes — anything other than
4854
5192
  // "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
4855
5193
  // real activity. Restating the instruction is a few tokens of cache-write and
4856
5194
  // buys a deterministic reply. keepaliveStamps matches every form, so none
4857
5195
  // reads as real activity. (round is incremented below, after the inject lands.)
4858
- const text = stalled ? kc.resumePing : kc.ping;
5196
+ const text = kc.ping;
4859
5197
  // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
4860
5198
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
4861
5199
  // the turn somehow never emits turn_end, so a later real turn is never muted;
@@ -4902,13 +5240,11 @@ export const startMirror = (deps) => {
4902
5240
  return;
4903
5241
  keepaliveTicking = true;
4904
5242
  try {
4905
- const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
4906
- const ttlMs = kc.ttlSec * 1000;
4907
- const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
5243
+ const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
4908
5244
  const now = Date.now();
4909
5245
  for (const a of byTarget.values()) {
4910
5246
  if (a.keepaliveDisabled)
4911
- continue; // sticky spawn-time opt-out — never pings, never auto-resumes
5247
+ continue; // sticky spawn-time opt-out — never pings
4912
5248
  if (!a.tmuxPane)
4913
5249
  continue; // spawn-mode: no live pane to warm
4914
5250
  if (a.migrationWatcher)
@@ -4942,6 +5278,8 @@ export const startMirror = (deps) => {
4942
5278
  k.lastMs = mtime;
4943
5279
  k.lastRealMs = mtime;
4944
5280
  } // backend without timestamps → fall back to mtime
5281
+ k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5282
+ k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
4945
5283
  }
4946
5284
  k.seenMtime = mtime;
4947
5285
  // Round reset / real-activity re-anchor keys off REAL turns only — a
@@ -4979,22 +5317,25 @@ export const startMirror = (deps) => {
4979
5317
  }
4980
5318
  if (a.keepaliveOff)
4981
5319
  continue; // paused by /stop until real activity returns
4982
- const idleSinceTouch = k.lastMs ? now - k.lastMs : now - mtime; // last model turn = cache touch
4983
- if (idleSinceTouch < idleTriggerMs)
5320
+ // Cache clock = start of the newest answered request (TTL runs from there),
5321
+ // not the last turn's write time; no request on record → the turn clock.
5322
+ const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5323
+ // The TTL the session's cache entries were actually written with (1h on a
5324
+ // Claude Code subscription), not a guess: pinging at the 5min cadence
5325
+ // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5326
+ const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5327
+ const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5328
+ if (sinceCache < idleTriggerMs)
4984
5329
  continue; // cache still comfortably warm
4985
- if (idleSinceTouch >= ttlMs)
5330
+ if (sinceCache >= ttlMs)
4986
5331
  continue; // cache already cold — a ping would cold-rewrite for nothing
5332
+ if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5333
+ continue; // idle past 10min (a long-TTL entry) — not worth warming
4987
5334
  if (k.round >= kc.rounds)
4988
5335
  continue; // budget spent — let it go cold
4989
- // Stall recovery, decided by RULE only (no model self-judgment) and from
4990
- // the transcript's structure only — never the screen: the turn is still
4991
- // unfinished (died on a synthetic API error, or a tool result nobody
4992
- // answered) and has been quiet for the whole idle window. Send the resume
4993
- // instruction instead of the plain warmer.
4994
- const stalled = kc.resumeOnStall && transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), idleTriggerMs, now);
4995
5336
  k.pinging = true;
4996
5337
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
4997
- await fireKeepalive(a, stalled);
5338
+ await fireKeepalive(a);
4998
5339
  }
4999
5340
  }
5000
5341
  catch (e) {
@@ -5150,6 +5491,7 @@ export const startMirror = (deps) => {
5150
5491
  peekTurns,
5151
5492
  isBusy,
5152
5493
  idleNow,
5494
+ parkedNow,
5153
5495
  untilIdle,
5154
5496
  setModel: async (target, wanted, scope) => {
5155
5497
  const a = byTarget.get(target);
@@ -5164,6 +5506,19 @@ export const startMirror = (deps) => {
5164
5506
  }
5165
5507
  return r;
5166
5508
  },
5509
+ setEffort: async (target, effort) => {
5510
+ const a = byTarget.get(target);
5511
+ if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5512
+ return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
5513
+ const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
5514
+ if (r.ok && r.applied) {
5515
+ a.effort = r.applied;
5516
+ const rec = deps.store.get(target);
5517
+ if (rec)
5518
+ deps.store.set(target, { ...rec, effort: r.applied });
5519
+ }
5520
+ return r;
5521
+ },
5167
5522
  transcripts: () => allTargets().flatMap((target) => {
5168
5523
  const jsonlPath = jsonlOf(target);
5169
5524
  if (!jsonlPath)
@@ -5368,34 +5723,29 @@ export const startMirror = (deps) => {
5368
5723
  rememberInject(full);
5369
5724
  // 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
5370
5725
  // resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
5371
- if (origin)
5372
- a.pendingOrigin = { origin, at: Date.now() };
5373
- if (opts?.from)
5374
- a.pendingFrom = { from: opts.from, at: Date.now() };
5375
- if (opts?.channel !== undefined)
5376
- a.pendingChannel = { channel: opts.channel, at: Date.now() };
5377
- // 注入的那一行会被 recentInjects 当回显吞掉, 走不到 user_text —— 本轮的 query
5378
- // (rolepage 上「谁对它说了什么」那一半) 只能在这里记。
5379
- a.pendingBriefQuery = text;
5726
+ // 印章随这一行走 (见 Seal): 行露面时才落成那一轮的出处 / 频道 / 问话。
5727
+ pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
5380
5728
  const sid = a.sessionId;
5381
5729
  const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
5730
+ // 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
5731
+ // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
5732
+ let fresh = a.justSpawned === true;
5382
5733
  if (!paneAlive) {
5383
5734
  log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
5384
5735
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
5385
5736
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
5386
5737
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
5387
- const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
5738
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, knownModels: modelSightings(backendForPath(a.jsonlPath).name, [target]), effort: parseEffort(a.effort), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
5388
5739
  if (!r.ok || !r.tmuxPane)
5389
5740
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
5390
5741
  a.tmuxPane = r.tmuxPane;
5391
5742
  a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
5392
5743
  if (r.cwd)
5393
5744
  a.runningCwd = r.cwd;
5394
- deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, pendingCwd: a.pendingCwd || undefined });
5395
- startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
5745
+ deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, effort: a.effort || undefined, pendingCwd: a.pendingCwd || undefined });
5746
+ startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5747
+ fresh = true;
5396
5748
  }
5397
- // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
5398
- // is still warming up so the verifier in injectViaTmux needs the slack.
5399
5749
  // 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
5400
5750
  a.muteUntilInject = false;
5401
5751
  a.justSpawned = false;
@@ -5405,7 +5755,7 @@ export const startMirror = (deps) => {
5405
5755
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
5406
5756
  const r = await inject({
5407
5757
  text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
5408
- sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
5758
+ sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
5409
5759
  });
5410
5760
  if (r.ok && a.tmuxPane) {
5411
5761
  if (preClearBaseline) {
@@ -5507,6 +5857,27 @@ export const startMirror = (deps) => {
5507
5857
  return 0;
5508
5858
  }
5509
5859
  },
5860
+ reapUnborn: async (names) => {
5861
+ const want = new Set(names.flatMap((n) => [n, `.${n}`]));
5862
+ if (!want.size)
5863
+ return [];
5864
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}"]);
5865
+ if (snap.code !== 0)
5866
+ return [];
5867
+ const bound = new Set(allTargets().map(paneOf).filter(Boolean));
5868
+ const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
5869
+ .filter(([pane = "", session, win = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && !bound.has(pane))
5870
+ .map(([pane = ""]) => pane);
5871
+ const killed = [];
5872
+ for (const pane of doomed) {
5873
+ // 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
5874
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
5875
+ await sleep(250);
5876
+ if ((await runTmux(["kill-pane", "-t", pane])).code === 0)
5877
+ killed.push(pane);
5878
+ }
5879
+ return killed;
5880
+ },
5510
5881
  reapOverflow: async (max, skip) => {
5511
5882
  const now = Date.now();
5512
5883
  const reaped = [];
@@ -5613,6 +5984,13 @@ export const startMirror = (deps) => {
5613
5984
  log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
5614
5985
  return { ok: true };
5615
5986
  },
5987
+ retitlePane: async (target) => {
5988
+ const pane = paneOf(target);
5989
+ if (!pane)
5990
+ return { ok: false, reason: "no tmux pane bound for target" };
5991
+ const r = await renameWindow(pane, displayName(target) || target);
5992
+ return r.code === 0 ? { ok: true } : { ok: false, reason: `rename-window failed: ${r.stderr.trim() || r.code}` };
5993
+ },
5616
5994
  revealPane: async (target) => {
5617
5995
  // paneOf (not byTarget.get) so a cold binding surviving only in the
5618
5996
  // persisted store can still be revealed after a daemon reload.
@@ -5686,6 +6064,7 @@ export const startMirror = (deps) => {
5686
6064
  sessionInfo,
5687
6065
  setCharterProvider,
5688
6066
  onSpawn,
6067
+ onContextCut,
5689
6068
  shutdown: () => {
5690
6069
  clearInterval(paneDriftTimer);
5691
6070
  clearInterval(keepaliveTimer);
@@ -5742,7 +6121,9 @@ export const startMirror = (deps) => {
5742
6121
  }
5743
6122
  persistPause(a); // persist the pause/resume too, else a reload would revert it
5744
6123
  // 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
5745
- a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
6124
+ // brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
6125
+ if (!cfg.wrc.mirror.brief || isClearCommand(text))
6126
+ a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
5746
6127
  // Finalize prior live stream (if any) so this new turn renders into its
5747
6128
  // own message bubble. Then open a fresh stream tied to the new frame and
5748
6129
  // ack immediately so WeCom doesn't time out while inject queues.
@@ -5836,7 +6217,7 @@ export const startMirror = (deps) => {
5836
6217
  // 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
5837
6218
  // 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
5838
6219
  // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
5839
- await startBriefTurn(a, frame, streamId, isSlash, text);
6220
+ await startBriefTurn(a, frame, streamId, isSlash, text, channel ?? baseOfKey(principal), speaker);
5840
6221
  }
5841
6222
  else if (!armMigration && !eagerOpen) {
5842
6223
  enterDeferred(a, frame, streamId);
@@ -5871,7 +6252,7 @@ export const startMirror = (deps) => {
5871
6252
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
5872
6253
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
5873
6254
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
5874
- const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
6255
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, knownModels: modelSightings(backendForPath(a.jsonlPath).name, [a.target]), effort: parseEffort(a.effort), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
5875
6256
  if (r.ok && r.tmuxPane && r.tmuxSession) {
5876
6257
  a.tmuxPane = r.tmuxPane;
5877
6258
  a.tmuxSession = r.tmuxSession;
@@ -5885,6 +6266,7 @@ export const startMirror = (deps) => {
5885
6266
  tmuxPane: a.tmuxPane,
5886
6267
  cwd: a.runningCwd || undefined,
5887
6268
  model: a.model || undefined,
6269
+ effort: a.effort || undefined,
5888
6270
  pendingCwd: a.pendingCwd || undefined,
5889
6271
  });
5890
6272
  log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
@@ -5894,7 +6276,7 @@ export const startMirror = (deps) => {
5894
6276
  // file appears, the watcher times out harmlessly, and the existing
5895
6277
  // tail keeps working — safe under either behavior.
5896
6278
  if (resumeBaseline)
5897
- startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
6279
+ startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5898
6280
  }
5899
6281
  else {
5900
6282
  // Drop only the stale pane id; keep tmuxSession (if any) so the