wezard 2.2.2 → 2.3.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 (94) hide show
  1. package/CLAUDE.md +7 -4
  2. package/dist/cli/init.js +2 -1
  3. package/dist/cli/init.js.map +1 -1
  4. package/dist/daemon/approval.js +6 -4
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/chat-log.js +6 -1
  7. package/dist/daemon/chat-log.js.map +1 -1
  8. package/dist/daemon/danger.js +17 -0
  9. package/dist/daemon/danger.js.map +1 -1
  10. package/dist/daemon/detail.js +41 -0
  11. package/dist/daemon/detail.js.map +1 -1
  12. package/dist/daemon/handoff.js +8 -2
  13. package/dist/daemon/handoff.js.map +1 -1
  14. package/dist/daemon/inbound.js +9 -4
  15. package/dist/daemon/inbound.js.map +1 -1
  16. package/dist/daemon/index.js +744 -152
  17. package/dist/daemon/index.js.map +1 -1
  18. package/dist/daemon/jobs.js +62 -23
  19. package/dist/daemon/jobs.js.map +1 -1
  20. package/dist/daemon/last-response.js +15 -0
  21. package/dist/daemon/last-response.js.map +1 -1
  22. package/dist/daemon/memory-steward.js +124 -68
  23. package/dist/daemon/memory-steward.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +461 -91
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/mirror-store.js.map +1 -1
  27. package/dist/daemon/model-select.js +99 -0
  28. package/dist/daemon/model-select.js.map +1 -1
  29. package/dist/daemon/notices.js +7 -5
  30. package/dist/daemon/notices.js.map +1 -1
  31. package/dist/daemon/peers.js +183 -60
  32. package/dist/daemon/peers.js.map +1 -1
  33. package/dist/daemon/receipts.js +391 -77
  34. package/dist/daemon/receipts.js.map +1 -1
  35. package/dist/daemon/route.js +63 -10
  36. package/dist/daemon/route.js.map +1 -1
  37. package/dist/daemon/spawn-tmux.js +24 -13
  38. package/dist/daemon/spawn-tmux.js.map +1 -1
  39. package/dist/daemon/task-registry.js +1 -1
  40. package/dist/daemon/task-registry.js.map +1 -1
  41. package/dist/daemon/tasks.js +12 -4
  42. package/dist/daemon/tasks.js.map +1 -1
  43. package/dist/daemon/usage.js +4 -11
  44. package/dist/daemon/usage.js.map +1 -1
  45. package/dist/daemon/wizard-memory.js +36 -4
  46. package/dist/daemon/wizard-memory.js.map +1 -1
  47. package/dist/daemon/wizard.js +102 -58
  48. package/dist/daemon/wizard.js.map +1 -1
  49. package/dist/mcp/server.js +138 -111
  50. package/dist/mcp/server.js.map +1 -1
  51. package/dist/shared/charter-view.js +35 -0
  52. package/dist/shared/charter-view.js.map +1 -0
  53. package/dist/shared/chat-http.js +133 -31
  54. package/dist/shared/chat-http.js.map +1 -1
  55. package/dist/shared/chat-render.js +6 -3
  56. package/dist/shared/chat-render.js.map +1 -1
  57. package/dist/shared/chat-view.js +17 -6
  58. package/dist/shared/chat-view.js.map +1 -1
  59. package/dist/shared/cli-backends.js +2 -0
  60. package/dist/shared/cli-backends.js.map +1 -1
  61. package/dist/shared/config.js +9 -5
  62. package/dist/shared/config.js.map +1 -1
  63. package/dist/shared/daemon-token.js +18 -0
  64. package/dist/shared/daemon-token.js.map +1 -0
  65. package/dist/shared/detail-render.js +140 -16
  66. package/dist/shared/detail-render.js.map +1 -1
  67. package/dist/shared/detail-store.js +33 -12
  68. package/dist/shared/detail-store.js.map +1 -1
  69. package/dist/shared/effort.js +11 -0
  70. package/dist/shared/effort.js.map +1 -0
  71. package/dist/shared/model-prices.js +258 -0
  72. package/dist/shared/model-prices.js.map +1 -0
  73. package/dist/shared/pricing.js +55 -0
  74. package/dist/shared/pricing.js.map +1 -0
  75. package/dist/shared/reminder.js +18 -4
  76. package/dist/shared/reminder.js.map +1 -1
  77. package/dist/shared/role-render.js +172 -8
  78. package/dist/shared/role-render.js.map +1 -1
  79. package/dist/shared/role-view.js +89 -25
  80. package/dist/shared/role-view.js.map +1 -1
  81. package/dist/shared/session-label.js +4 -0
  82. package/dist/shared/session-label.js.map +1 -1
  83. package/dist/shared/task-file.js +16 -1
  84. package/dist/shared/task-file.js.map +1 -1
  85. package/dist/shared/turn-state.js +24 -0
  86. package/dist/shared/turn-state.js.map +1 -0
  87. package/dist/shared/world.js +37 -3
  88. package/dist/shared/world.js.map +1 -1
  89. package/dist/svr/index.js +2 -1
  90. package/dist/svr/index.js.map +1 -1
  91. package/hooks/pre-tool-use.sh +16 -3
  92. package/package.json +1 -1
  93. package/web/chat.css +172 -40
  94. package/web/chat.js +725 -247
@@ -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, transcriptStalled, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, 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).
@@ -472,6 +475,10 @@ const recoverToolUse = (toolUseId, deps) => {
472
475
  }
473
476
  return undefined;
474
477
  };
478
+ const lineTs = (line) => {
479
+ const ts = Date.parse(line.timestamp ?? "");
480
+ return Number.isNaN(ts) ? undefined : ts;
481
+ };
475
482
  // Render one transcript line into tagged items. Caller decides batching.
476
483
  const renderLine = (raw, deps) => {
477
484
  let line;
@@ -499,6 +506,13 @@ const renderLine = (raw, deps) => {
499
506
  const goal = line.type === "user" ? goalStartOf(line.message?.content) : undefined;
500
507
  return goal ? [goal] : [];
501
508
  }
509
+ // 插话: 正在跑的这一轮中途吃进去的那句, CLI 只落这一行 attachment, 没有 user 行 ——
510
+ // 不报到的话, 它的印章没人认领, 这句话在详情里就不存在。
511
+ if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
512
+ const said = cleanUserText(line.attachment.prompt);
513
+ const ts = Date.parse(line.timestamp ?? "");
514
+ return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
515
+ }
502
516
  const out = [];
503
517
  if (line.type === "user") {
504
518
  const c = line.message?.content;
@@ -544,12 +558,13 @@ const renderLine = (raw, deps) => {
544
558
  // cover reloads or replays; the user message itself always can.
545
559
  if (deps.isKeepalivePing?.(text))
546
560
  return [{ kind: "keepalive_start", body: text }];
561
+ // 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
547
562
  if (deps.isOwnInject(text))
548
- return []; // dedupe WeCom→CLI echo
563
+ return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
549
564
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
550
565
  // includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
551
566
  // onItem 消费完边界语义后自己丢掉。
552
- out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser });
567
+ out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser, ts: lineTs(line) });
553
568
  }
554
569
  else if (Array.isArray(c) && hasImages(c) && !c.some((b) => b?.type === "tool_result")) {
555
570
  // 带图的一句话: [text "[Image #1]…", image, …]。去掉占位符才与注入时记下的原文同形,
@@ -557,10 +572,10 @@ const renderLine = (raw, deps) => {
557
572
  const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
558
573
  const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
559
574
  if (text && !deps.isOwnInject(text)) {
560
- out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser });
575
+ out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
561
576
  }
562
577
  else {
563
- out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text });
578
+ out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
564
579
  }
565
580
  }
566
581
  else if (Array.isArray(c)) {
@@ -672,6 +687,7 @@ const renderLine = (raw, deps) => {
672
687
  const u = line.message?.usage;
673
688
  if (u) {
674
689
  const model = typeof line.message?.model === "string" ? line.message.model : undefined;
690
+ const effort = typeof line.effort === "string" && line.effort ? line.effort : undefined;
675
691
  const messageId = typeof line.message?.id === "string" ? line.message.id : undefined;
676
692
  const rawIn = u.input_tokens ?? 0;
677
693
  const cr = u.cache_read_input_tokens ?? 0;
@@ -685,6 +701,7 @@ const renderLine = (raw, deps) => {
685
701
  out.push({
686
702
  kind: "turn_usage",
687
703
  model,
704
+ effort,
688
705
  messageId,
689
706
  usage: {
690
707
  input,
@@ -849,6 +866,9 @@ export const startMirrorTail = (deps) => {
849
866
  buffer = buffer.slice(nl + 1);
850
867
  if (!line.trim())
851
868
  continue;
869
+ // 压缩边界: 对话被摘要替换, 系统提示 (宪章) 不变 —— 同 /clear 一样要补记忆。
870
+ if (deps.onCompact && line.includes('"compact_boundary"'))
871
+ deps.onCompact();
852
872
  for (const item of renderLine(line, deps))
853
873
  deps.onItem(item);
854
874
  }
@@ -1413,6 +1433,8 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1413
1433
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1414
1434
  // 每次补之前再问一遍证人, 已提交就不补。
1415
1435
  const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1436
+ /** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
1437
+ let pasteSeq = 0;
1416
1438
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1417
1439
  // started, transcript still loading): extended timings — bracketed-paste
1418
1440
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1452,10 +1474,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1452
1474
  const loadAndPaste = async () => {
1453
1475
  // stdin variant of the shared exec path — it carries the same hard timeout,
1454
1476
  // which a hand-rolled spawn here did not.
1455
- const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
1477
+ // 每次注入用自己的具名 buffer: 不同 pane 的注入并发时, 共用 tmux 的默认 buffer 会让
1478
+ // 一方 load 之后、paste 之前被另一方覆盖 —— 两段文本互换 pane (实测: 同一毫秒投的两份
1479
+ // 回执, 发给 A 的进了 B)。同一 pane 的串行管不到跨 pane。
1480
+ const buf = `wezard-${process.pid}-${++pasteSeq}`;
1481
+ const loaded = await runTmux(["load-buffer", "-b", buf, "-"], { stdin: text });
1456
1482
  if (!loaded.ok)
1457
1483
  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]);
1484
+ const pasted = await runTmux(["paste-buffer", "-b", buf, "-p", "-d", "-t", target]);
1459
1485
  if (!pasted.ok)
1460
1486
  return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1461
1487
  return { ok: true };
@@ -1760,7 +1786,9 @@ export const startMirror = (deps) => {
1760
1786
  // The warmer alone — what the peer readers (peek / wait / roster summary) strip.
1761
1787
  // `resumePing` is excluded on purpose: the turn it opens is real work resumed.
1762
1788
  const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1763
- const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
1789
+ // 行上只认保温那一句: 报错之后的续跑 (resumePing) 开出的是那一轮的接续, 整轮吞掉的话
1790
+ // 续出来的结论既不进群也没有频道。续跑若只回了 pong, 回复侧的那道保险照样吞。
1791
+ const isKeepalivePing = (text) => isKeepalivePingText(text, warmerSigs);
1764
1792
  // ── Typewriter stream lifecycle ────────────────────────────────────
1765
1793
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1766
1794
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -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,20 @@ 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;
2385
2440
  }
2441
+ return undefined;
2386
2442
  };
2387
2443
  /** 本轮落 turn 记录的频道字段。 */
2388
2444
  const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
@@ -2395,14 +2451,93 @@ export const startMirror = (deps) => {
2395
2451
  // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2396
2452
  // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2397
2453
  const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
2398
- const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
2399
- const turnId = newTurnId();
2454
+ // ── 印章 (见 Seal) ──────────────────────────────────────────────
2455
+ // 盖在行上而不是格子里: 注入时记下, 行在 transcript 里露面时认领。比回显去重的 60s 活得久 ——
2456
+ // 排在长轮后面的那一行露面时早过了 60s, 认不出就会被当成人在 CLI 里敲的。
2457
+ const SEAL_TTL_MS = 2 * 3600_000;
2458
+ const sealKeys = (text) => {
2459
+ const t = cleanUserText(text);
2460
+ if (!t.startsWith("/"))
2461
+ return t ? [t] : [];
2462
+ const head = t.split(/\s+/, 1)[0] ?? t;
2463
+ const args = t.slice(head.length).trim();
2464
+ return [t, `\`${head}${args ? ` ${args}` : ""}\``];
2465
+ };
2466
+ const pushSeal = (a, s) => {
2467
+ const now = Date.now();
2468
+ a.seals = [...(a.seals ?? []).filter((x) => now - x.at <= SEAL_TTL_MS), s];
2469
+ };
2470
+ /** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
2471
+ * 露过面, 出处取第一枚。认不出 = undefined。 */
2472
+ const takeSeal = (a, said, pick = () => true) => {
2473
+ const t = said.trim();
2474
+ const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
2475
+ const seals = a.seals ?? [];
2476
+ const first = seals.find(hit);
2477
+ if (first)
2478
+ a.seals = seals.filter((x) => !hit(x));
2479
+ return first;
2480
+ };
2481
+ /** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
2482
+ * `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
2483
+ const activateDispatch = (a, s, at = s.at) => {
2484
+ const q = s.turn;
2485
+ s.activated = true;
2400
2486
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2401
2487
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2402
2488
  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) });
2489
+ // 人说的这一轮没有同伴出处: 别的行认领了、还没开出轮的印章被这一句接管, 一并作废。
2490
+ a.pendingFrom = undefined;
2491
+ a.pendingOrigin = undefined;
2492
+ a.pendingBriefQuery = undefined;
2493
+ a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
2494
+ consumeChannel(a, true, undefined);
2495
+ 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) });
2496
+ if (a.briefTurnId)
2497
+ closeBriefTurn(a);
2498
+ openBriefTurn(a, q);
2499
+ };
2500
+ /** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
2501
+ 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 } : {}) });
2502
+ /** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
2503
+ * 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
2504
+ * 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
2505
+ const interject = (a, said, ts) => {
2506
+ if (!a.briefTurnId)
2507
+ return;
2508
+ const s = takeSeal(a, said, (x) => !x.turn);
2509
+ if (s)
2510
+ injectInto(a.briefTurnId, a, s, ts);
2511
+ };
2512
+ /** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
2513
+ const bindLine = (a, said, ts) => {
2514
+ const s = takeSeal(a, said);
2515
+ if (!s)
2516
+ return undefined;
2517
+ if (s.turn) {
2518
+ if (!s.activated)
2519
+ activateDispatch(a, s, ts);
2520
+ return s;
2521
+ }
2522
+ // 忙时贴进去、正在跑的那一轮还没出终句: 它是插进那一轮的话 (priority now), 那一轮的
2523
+ // 回复仍归原来那一方, 这句记成那一轮的一条插话。闲时贴进去的、或上一轮已经说完的, 才是新一轮的开头。
2524
+ if (a.briefTurnId && !a.briefConcluded && !s.fresh) {
2525
+ injectInto(a.briefTurnId, a, s, Date.now());
2526
+ return s;
2527
+ }
2528
+ if (a.briefTurnId)
2529
+ closeBriefTurn(a);
2530
+ const now = Date.now();
2531
+ a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
2532
+ a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
2533
+ // 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
2534
+ a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
2535
+ a.pendingBriefQuery = s.query;
2536
+ a.pendingBriefAt = ts;
2537
+ return s;
2538
+ };
2539
+ const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
2540
+ const turnId = newTurnId();
2406
2541
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2407
2542
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2408
2543
  const q = { turnId, bubble, isSlash };
@@ -2413,15 +2548,23 @@ export const startMirror = (deps) => {
2413
2548
  const live = a.briefBubble === bubble && !a.briefConcluded;
2414
2549
  void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
2415
2550
  }, HARD_TIMEOUT_MS);
2551
+ const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
2552
+ // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
2553
+ // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
2554
+ // 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
2555
+ const queued = !isSlash && idleVerdict(a.target) === false;
2556
+ pushSeal(a, seal);
2557
+ if (queued)
2558
+ 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 });
2559
+ else
2560
+ activateDispatch(a, seal);
2416
2561
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
2417
2562
  // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
2418
2563
  // 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
2419
2564
  // 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
2420
2565
  // 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
2421
- // replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。
2422
- if (a.briefTurnId)
2423
- closeBriefTurn(a);
2424
- openBriefTurn(a, q);
2566
+ // replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
2567
+ // 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
2425
2568
  // ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
2426
2569
  // turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
2427
2570
  // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
@@ -2433,6 +2576,35 @@ export const startMirror = (deps) => {
2433
2576
  log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
2434
2577
  }
2435
2578
  };
2579
+ /** reload 后的第一笔产出: 那一轮多半还是旧进程开的那条记录 (内存里的 briefTurnId 随进程没了) ——
2580
+ * 另开一轮就把一件活切成几段、每段都带着开头那句。只在 transcript 最后那句问话正是它的问话时接回,
2581
+ * 频道与出处取它自己记下的; 对不上 (旧进程收口前就有了新问话) 照常开新的。 */
2582
+ const resumeOpenTurn = (a) => {
2583
+ if (a.channelSeeded)
2584
+ return false;
2585
+ const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
2586
+ const said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
2587
+ const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
2588
+ if (!r)
2589
+ return false;
2590
+ a.channelSeeded = true;
2591
+ a.pendingFrom = undefined;
2592
+ a.pendingOrigin = undefined;
2593
+ a.pendingChannel = undefined;
2594
+ a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
2595
+ a.speaker = r.speaker;
2596
+ a.convFrom = r.from;
2597
+ a.replyTo = publicPeer(r.from);
2598
+ a.turnFromChat = a.turnFromChat ?? true;
2599
+ a.briefTurnId = r.id;
2600
+ a.briefBubble = undefined;
2601
+ a.briefIsSlash = false;
2602
+ a.briefHadTool = r.items.some((it) => it.t === "tool_use");
2603
+ a.briefConcluded = false;
2604
+ a.briefLastText = undefined;
2605
+ log.info({ sessionId: a.sessionId, turnId: r.id }, "brief: turn resumed after reload");
2606
+ return true;
2607
+ };
2436
2608
  // 无气泡 turn。CLI 侧自己开的一轮 (WeCom 从没发过消息, 拿不到 frame/streamId), 以及
2437
2609
  // 收口后仍有 item 补写进来的情况, 都要有一个 turn 承接 —— 否则每条 tool/text 都掉进
2438
2610
  // fallback standalone, 一轮工具密集的对话能在群里刷出几十条散装气泡。这里只推一条
@@ -2440,18 +2612,22 @@ export const startMirror = (deps) => {
2440
2612
  const ensureBriefTurn = (a) => {
2441
2613
  if (a.briefTurnId)
2442
2614
  return;
2443
- const turnId = newTurnId();
2444
2615
  const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
2616
+ const at = a.pendingBriefAt;
2445
2617
  a.pendingBriefQuery = undefined;
2618
+ a.pendingBriefAt = undefined;
2619
+ if (!query && a.pendingFromCli !== true && resumeOpenTurn(a))
2620
+ return;
2621
+ const turnId = newTurnId();
2446
2622
  // 出处继承: 有真人 CLI 输入行开头 → CLI 轮; 否则 (收口后的补写、peer/graph 注入
2447
2623
  // 开出的轮) 沿用上一轮 —— 那些续写属于同一场对话, 不该改变可见性。
2448
2624
  const fromCli = a.pendingFromCli === true;
2449
2625
  a.pendingFromCli = false;
2450
2626
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2451
2627
  const fresh = consumeFrom(a);
2452
- consumeChannel(a, fromCli, fresh);
2628
+ const opened = consumeChannel(a, fromCli, fresh);
2453
2629
  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) });
2630
+ 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
2631
  a.briefTurnId = turnId;
2456
2632
  a.briefBubble = undefined;
2457
2633
  a.briefIsSlash = false;
@@ -2781,7 +2957,7 @@ export const startMirror = (deps) => {
2781
2957
  return;
2782
2958
  }
2783
2959
  if (item.kind === "turn_usage") {
2784
- recordTurnUsage(turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
2960
+ recordTurnUsage(turnId, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
2785
2961
  return;
2786
2962
  }
2787
2963
  if (item.kind === "skill_output") {
@@ -2983,6 +3159,10 @@ export const startMirror = (deps) => {
2983
3159
  // (crashed reply). Release BEFORE the swallow gate so this real line (and
2984
3160
  // its reply) mirror normally instead of being eaten by the stale swallow.
2985
3161
  if (item.kind === "user_text") {
3162
+ // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3163
+ // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
3164
+ if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
3165
+ return;
2986
3166
  endKeepaliveSwallow(a);
2987
3167
  // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
2988
3168
  // 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
@@ -2992,13 +3172,24 @@ export const startMirror = (deps) => {
2992
3172
  // quiet (includeUser=false, 默认) 同样记: includeUser 只决定气泡渲不渲染,
2993
3173
  // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
2994
3174
  a.pendingBriefQuery = item.body;
3175
+ a.pendingBriefAt = item.ts;
2995
3176
  a.pendingFromCli = true;
2996
3177
  }
3178
+ if (item.kind === "user_inject") {
3179
+ interject(a, item.said, item.ts);
3180
+ return;
3181
+ }
2997
3182
  // 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
2998
3183
  // 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
2999
3184
  if (item.kind === "user_query") {
3000
- if (a.pendingBriefQuery !== undefined || !a.briefTurnId)
3185
+ // 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
3186
+ const sealed = bindLine(a, item.said, item.ts);
3187
+ if (sealed && !sealed.turn)
3188
+ return;
3189
+ if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
3001
3190
  a.pendingBriefQuery = item.body;
3191
+ a.pendingBriefAt = item.ts;
3192
+ }
3002
3193
  // 只认同一句话: 轮次中途 CLI 里又粘了一张图, 不能改写正在跑的这一轮的问话。
3003
3194
  else
3004
3195
  recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
@@ -3032,7 +3223,7 @@ export const startMirror = (deps) => {
3032
3223
  recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
3033
3224
  }
3034
3225
  else if (item.kind === "turn_usage") {
3035
- recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
3226
+ recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
3036
3227
  }
3037
3228
  else if (item.kind === "turn_end") {
3038
3229
  recordTurnClose(id);
@@ -3375,7 +3566,7 @@ export const startMirror = (deps) => {
3375
3566
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
3376
3567
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
3377
3568
  : "";
3378
- const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
3569
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd, keepaliveDisabled }) => {
3379
3570
  const target = resolveTarget(targetOverride);
3380
3571
  if (!target)
3381
3572
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -3405,6 +3596,7 @@ export const startMirror = (deps) => {
3405
3596
  // model it asked for (possibly "" = CLI default) and says so; a re-attach
3406
3597
  // onto an already-running pane doesn't, so it keeps what was recorded.
3407
3598
  const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
3599
+ const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
3408
3600
  // Same carry rule again: an explicit spawn-time choice wins, otherwise this
3409
3601
  // wizard's existing policy (in-memory, or on disk across a reload) sticks.
3410
3602
  const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
@@ -3423,6 +3615,7 @@ export const startMirror = (deps) => {
3423
3615
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
3424
3616
  pendingCwd: carryPending,
3425
3617
  model: carryModel,
3618
+ effort: carryEffort,
3426
3619
  keepaliveDisabled: carryKeepaliveDisabled,
3427
3620
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
3428
3621
  standalonePending: Promise.resolve(),
@@ -3440,6 +3633,7 @@ export const startMirror = (deps) => {
3440
3633
  isOwnAssistantSend,
3441
3634
  isKeepalivePing,
3442
3635
  onItem: (item) => onItem(a, item),
3636
+ onCompact: () => contextCut(a.target, "compact"),
3443
3637
  detailUrlFor,
3444
3638
  sessionId,
3445
3639
  target,
@@ -3461,6 +3655,7 @@ export const startMirror = (deps) => {
3461
3655
  tmuxPane: a.tmuxPane || undefined,
3462
3656
  cwd: a.runningCwd || undefined,
3463
3657
  model: a.model || undefined,
3658
+ effort: a.effort || undefined,
3464
3659
  pendingCwd: a.pendingCwd || undefined,
3465
3660
  cwdConfirmed: prevRec?.cwdConfirmed,
3466
3661
  keepaliveOff: prevRec?.keepaliveOff,
@@ -3498,8 +3693,12 @@ export const startMirror = (deps) => {
3498
3693
  // it collapses N distinct peers onto one sessionId (attach() then dedupes by
3499
3694
  // sid and silently detaches all-but-one). Excluding claimed sids keeps each
3500
3695
  // peer on its own session.
3696
+ // 正在出生、还没 attach 的会话 sid: 新会话 (spawn 时 jsonl 就预建了) 与认下了的分叉
3697
+ // 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
3698
+ // jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
3699
+ const inFlightSids = new Set();
3501
3700
  const sidsClaimedByOthers = (principal) => {
3502
- const s = new Set();
3701
+ const s = new Set(inFlightSids);
3503
3702
  for (const [k, v] of Object.entries(deps.store.all()))
3504
3703
  if (k !== principal && v.sessionId)
3505
3704
  s.add(v.sessionId);
@@ -3609,6 +3808,7 @@ export const startMirror = (deps) => {
3609
3808
  tmuxSession: rec.tmuxSession ?? "",
3610
3809
  cwd: rec.cwd,
3611
3810
  model: rec.model,
3811
+ effort: rec.effort,
3612
3812
  pendingCwd: rec.pendingCwd,
3613
3813
  });
3614
3814
  if (!r.ok) {
@@ -3809,6 +4009,17 @@ export const startMirror = (deps) => {
3809
4009
  catch { /* unreadable */ }
3810
4010
  return undefined;
3811
4011
  };
4012
+ // A resume / --fork-session copy is seeded with the origin's history, uuids
4013
+ // intact — so "its first user line is ours" is pane-certain evidence that a
4014
+ // new jsonl is OUR fork. "Has any user content" is not: a sibling spawned in
4015
+ // the same cwd a moment earlier also has some, and its sid isn't claimed yet
4016
+ // (attach lands after the spawn) — that is how a respawned .evolve got bound
4017
+ // to a haiku sibling's fresh transcript and read empty from then on.
4018
+ const forkOf = (origin) => {
4019
+ const seed = firstUserUuid(origin);
4020
+ // origin 还没有 user 行 (没说过话就死了 / 刚开的父亲): 没有可比的种子, 退回旧判据。
4021
+ return seed === undefined ? (path) => firstUserUuid(path) !== undefined : (path) => firstUserUuid(path) === seed;
4022
+ };
3812
4023
  // startOffset semantics differ by caller: /clear passes 0 to replay the
3813
4024
  // freshly-rotated jsonl (only holds the /clear line + new content); resume
3814
4025
  // migration omits it (→ tail from EOF) because the fork file is seeded with
@@ -3829,6 +4040,8 @@ export const startMirror = (deps) => {
3829
4040
  // drift 上下文是延续的, 画中性的 "switch"。
3830
4041
  const cleared = jsonlIsPostClearChild(newJsonlPath);
3831
4042
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4043
+ if (cleared)
4044
+ contextCut(a.target, "clear");
3832
4045
  if (cleared) {
3833
4046
  // 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
3834
4047
  // dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
@@ -3855,6 +4068,7 @@ export const startMirror = (deps) => {
3855
4068
  isOwnAssistantSend,
3856
4069
  isKeepalivePing,
3857
4070
  onItem: (item) => onItem(a, item),
4071
+ onCompact: () => contextCut(a.target, "compact"),
3858
4072
  detailUrlFor,
3859
4073
  sessionId: newSessionId,
3860
4074
  target: a.target,
@@ -4057,6 +4271,9 @@ export const startMirror = (deps) => {
4057
4271
  // Post-/clear: only transcripts that didn't exist before the rotation
4058
4272
  // can be ours, which is what licenses the shorter fingerprint above.
4059
4273
  .filter((n) => !pendingClear || !pendingClear.baseline.has(n))
4274
+ // 别人的 (含还在出生、没 attach 的新会话与分叉 —— 分叉文件带着父亲注进去的原话,
4275
+ // 指纹会撞上) 不认。
4276
+ .filter((n) => !sidsClaimedByOthers(a.target).has(n.replace(/\.jsonl$/, "")))
4060
4277
  .map((n) => ({ n, m: (() => { try {
4061
4278
  return statSync(join(dir, n)).mtimeMs;
4062
4279
  }
@@ -4199,6 +4416,17 @@ export const startMirror = (deps) => {
4199
4416
  // 新 pane 落地后的钩子 —— pane 上限在这里数 (由 index.ts 装上; 没装 = 不数)。
4200
4417
  let afterSpawn = () => { };
4201
4418
  const onSpawn = (fn) => { afterSpawn = fn; };
4419
+ let afterCut = () => { };
4420
+ const onContextCut = (fn) => { afterCut = fn; };
4421
+ const contextCut = (target, cut) => {
4422
+ log.info({ target, cut }, "context cut");
4423
+ try {
4424
+ afterCut(target, cut);
4425
+ }
4426
+ catch (e) {
4427
+ log.warn({ target, cut, err: e.message }, "onContextCut hook threw");
4428
+ }
4429
+ };
4202
4430
  // `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
4203
4431
  // 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
4204
4432
  // 旧工作区里。
@@ -4210,6 +4438,32 @@ export const startMirror = (deps) => {
4210
4438
  return undefined;
4211
4439
  }
4212
4440
  };
4441
+ // 确切模型名的证据 (见 exactModelId): transcript 里 API 真答过的 model id, 只认同一个
4442
+ // CLI 的。先问最可能的那几个会话 (自己、被克隆者), 再按新近扫其余 —— 惰性的,
4443
+ // 第一个对上就停, 绝大多数时候只读一两个 transcript 的尾巴。
4444
+ function* modelSightings(cli, likely) {
4445
+ const name = cli ?? primaryBackend().name;
4446
+ const pathOf = (t) => byTarget.get(t)?.jsonlPath ?? expandHome(deps.store.get(t)?.jsonlPath ?? "");
4447
+ const head = likely.map(pathOf).filter(Boolean);
4448
+ // 生成器体到第一次取值才跑: 前几个就对上时, 其余几百个 stat 一个也不做。
4449
+ const paths = function* () {
4450
+ yield* head;
4451
+ yield* Object.values(deps.store.all())
4452
+ .map((r) => expandHome(r.jsonlPath))
4453
+ .filter((p) => !head.includes(p))
4454
+ .map((p) => ({ p, m: mtimeOf(p) }))
4455
+ .filter((x) => x.m > 0)
4456
+ .sort((x, y) => y.m - x.m)
4457
+ .map((x) => x.p);
4458
+ };
4459
+ for (const path of paths()) {
4460
+ if (!existsSync(path) || backendForPath(path).name !== name)
4461
+ continue;
4462
+ const id = lastModel(path);
4463
+ if (id)
4464
+ yield id;
4465
+ }
4466
+ }
4213
4467
  const newSession = async (target, windowName, cli, opts) => {
4214
4468
  const prev = byTarget.get(target);
4215
4469
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
@@ -4241,23 +4495,32 @@ export const startMirror = (deps) => {
4241
4495
  const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
4242
4496
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
4243
4497
  const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
4498
+ const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
4244
4499
  if (prev?.tmuxPane) {
4245
4500
  // Best-effort kill; ignore errors (pane may already be dead).
4246
4501
  void runTmux(["kill-pane", "-t", prev.tmuxPane]);
4247
4502
  }
4248
4503
  if (prev)
4249
4504
  detach(prev, "/new respawn");
4505
+ // sid 由这里先定, 出生到 attach 之间挂在 inFlightSids 上 (见那里)。
4506
+ const freshSid = randomUUID();
4507
+ inFlightSids.add(freshSid);
4250
4508
  const r = await spawnTmuxClaude({
4251
4509
  cfg,
4252
4510
  log: log.child({ sub: "new-session", target }),
4511
+ sessionId: freshSid,
4253
4512
  windowName: windowName ?? target,
4254
4513
  cwdOverride: eff,
4255
4514
  cli: effCli,
4256
4515
  model: opts?.model,
4516
+ knownModels: modelSightings(effCli, [target, base]),
4517
+ effort: effEffort,
4257
4518
  systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
4258
- });
4259
- if (!r.ok)
4519
+ }).catch((e) => { inFlightSids.delete(freshSid); throw e; });
4520
+ if (!r.ok) {
4521
+ inFlightSids.delete(freshSid);
4260
4522
  return { ok: false, reason: r.reason };
4523
+ }
4261
4524
  const att = attach({
4262
4525
  sessionId: r.sessionId,
4263
4526
  jsonlPath: r.jsonlPath,
@@ -4272,8 +4535,10 @@ export const startMirror = (deps) => {
4272
4535
  // spawnTmuxClaude actually confirmed via `/model` (r.model), not the
4273
4536
  // raw request — a later respawn's fast path then matches on try one.
4274
4537
  model: r.model ?? (opts?.model?.trim() ?? ""),
4538
+ effort: r.effort ?? "",
4275
4539
  keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
4276
4540
  });
4541
+ inFlightSids.delete(freshSid);
4277
4542
  if (!att.ok)
4278
4543
  return { ok: false, reason: att.reason };
4279
4544
  // 首条注入吃冷时序(injectText 走 /mirror/spawn 时已经硬编码 freshSpawn:true,
@@ -4307,6 +4572,8 @@ export const startMirror = (deps) => {
4307
4572
  tmuxSession: baseA.tmuxSession || undefined,
4308
4573
  tmuxPane: baseA.tmuxPane || undefined,
4309
4574
  cwd: baseA.runningCwd || undefined,
4575
+ model: baseA.model || undefined,
4576
+ effort: baseA.effort || undefined,
4310
4577
  cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
4311
4578
  pendingCwd: undefined,
4312
4579
  });
@@ -4320,7 +4587,7 @@ export const startMirror = (deps) => {
4320
4587
  if (!opts?.silent)
4321
4588
  pushProjectInfo(target, "created");
4322
4589
  afterSpawn();
4323
- return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
4590
+ return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
4324
4591
  };
4325
4592
  // ── Clone ───────────────────────────────────────────────────────────
4326
4593
  // 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
@@ -4337,19 +4604,35 @@ export const startMirror = (deps) => {
4337
4604
  // (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
4338
4605
  const FORK_POLL_MS = 400;
4339
4606
  const FORK_WAIT_MS = 45_000;
4340
- const awaitFork = async (projectDir, baseline, claimed) => {
4341
- const deadline = Date.now() + FORK_WAIT_MS;
4607
+ // 认下的分叉文件同样记进 inFlightSids: 同一个父亲并发克隆出几个分身时, 每个分叉的首行
4608
+ // 都与父亲相同, 不预留的话两个 awaitFork 会认下同一个文件。
4609
+ const awaitFork = async (projectDir, baseline, claimed, origin, probe) => {
4610
+ const ours = forkOf(origin);
4611
+ const take = (sessionId, jsonlPath) => (inFlightSids.add(sessionId), { sessionId, jsonlPath });
4612
+ const start = Date.now();
4613
+ const deadline = start + FORK_WAIT_MS;
4342
4614
  while (Date.now() < deadline) {
4615
+ // 首选会话注册表: 它按 pane 报这个进程此刻的 sid —— 并发分叉时唯一不靠猜的来源。
4616
+ const live = probe.pane ? sessionOnPane(probe.homeDir, probe.pane) : undefined;
4617
+ if (live && live.sessionId !== probe.parentSid && !claimed.has(live.sessionId)) {
4618
+ const path = join(projectDir, `${live.sessionId}.jsonl`);
4619
+ if (existsSync(path) && ours(path))
4620
+ return take(live.sessionId, path);
4621
+ }
4343
4622
  const born = [...listJsonls(projectDir)]
4344
- .filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
4623
+ .filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")) && !inFlightSids.has(n.replace(/\.jsonl$/, "")))
4345
4624
  .map((n) => ({ n, path: join(projectDir, n) }))
4346
4625
  // 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
4347
- // 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读。
4348
- .filter((c) => firstUserUuid(c.path) !== undefined)
4626
+ // 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读; 别的 pane
4627
+ // 同时新开的会话也有 user 行, 只认首行 uuid 与父亲相同的那个。
4628
+ .filter((c) => ours(c.path))
4349
4629
  .sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
4350
- const hit = born[0];
4630
+ // 注册表报不出 (没有注册表的后端 / 这个 pane 没登记 / 一阵子了还报着父亲的 sid)
4631
+ // 才按「新出现、首行同父」去猜, 认下即预留。
4632
+ const guess = !hasRegistry(probe.homeDir) || !live || Date.now() - start > FORK_WAIT_MS / 3;
4633
+ const hit = guess ? born[0] : undefined;
4351
4634
  if (hit)
4352
- return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
4635
+ return take(hit.n.replace(/\.jsonl$/, ""), hit.path);
4353
4636
  await sleep(FORK_POLL_MS);
4354
4637
  }
4355
4638
  return undefined;
@@ -4362,7 +4645,7 @@ export const startMirror = (deps) => {
4362
4645
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4363
4646
  if (!canInherit) {
4364
4647
  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,
4648
+ model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
4366
4649
  });
4367
4650
  return { ...r, inherited: false };
4368
4651
  }
@@ -4370,6 +4653,10 @@ export const startMirror = (deps) => {
4370
4653
  const projectDir = dirname(parent.jsonlPath);
4371
4654
  const baseline = listJsonls(projectDir);
4372
4655
  const lg = log.child({ sub: "clone", target: args.target });
4656
+ // 分身默认跑被克隆者此刻真在跑的那个: 记下的模型, 没记 (CLI 默认) 就取它 transcript 里的 id。
4657
+ const parentCli = backendForPath(parent.jsonlPath).name;
4658
+ const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
4659
+ const effort = args.effort ?? parseEffort(parent.effort);
4373
4660
  const r = await spawnTmuxClaude({
4374
4661
  cfg,
4375
4662
  log: lg,
@@ -4377,8 +4664,10 @@ export const startMirror = (deps) => {
4377
4664
  forkSession: true,
4378
4665
  windowName: args.windowName ?? args.target,
4379
4666
  cwdOverride: parent.runningCwd,
4380
- cli: backendForPath(parent.jsonlPath).name,
4381
- model: args.model,
4667
+ cli: parentCli,
4668
+ model,
4669
+ knownModels: modelSightings(parentCli, [args.parent]),
4670
+ effort,
4382
4671
  systemPrompt: args.systemPrompt,
4383
4672
  });
4384
4673
  if (!r.ok)
@@ -4398,7 +4687,9 @@ export const startMirror = (deps) => {
4398
4687
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4399
4688
  return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
4400
4689
  }
4401
- const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
4690
+ const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
4691
+ homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
4692
+ });
4402
4693
  // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
4403
4694
  // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
4404
4695
  // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
@@ -4414,9 +4705,12 @@ export const startMirror = (deps) => {
4414
4705
  tmuxSession: r.tmuxSession,
4415
4706
  cwd: r.cwd,
4416
4707
  pendingCwd: "",
4417
- model: r.model ?? (args.model?.trim() ?? ""),
4708
+ model: r.model ?? model,
4709
+ effort: r.effort ?? "",
4418
4710
  keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
4419
4711
  });
4712
+ // 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
4713
+ inFlightSids.delete(fork.sessionId);
4420
4714
  if (!att.ok)
4421
4715
  return { ok: false, reason: att.reason, inherited: false };
4422
4716
  const spawned = byTarget.get(args.target);
@@ -4428,10 +4722,16 @@ export const startMirror = (deps) => {
4428
4722
  spawned.keepaliveOffAt = Date.now();
4429
4723
  spawned.keepalive = undefined;
4430
4724
  persistPause(spawned);
4725
+ // 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
4726
+ // 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
4727
+ if (args.bootstrapFrom) {
4728
+ spawned.pendingFrom = { from: args.bootstrapFrom.from, at: Date.now() };
4729
+ spawned.pendingBriefQuery = args.bootstrapFrom.query;
4730
+ }
4431
4731
  }
4432
4732
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4433
4733
  afterSpawn();
4434
- return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
4734
+ return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
4435
4735
  };
4436
4736
  /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
4437
4737
  const sessionInfo = (target) => {
@@ -4447,6 +4747,7 @@ export const startMirror = (deps) => {
4447
4747
  cwd: a?.runningCwd || rec?.cwd || "",
4448
4748
  cli: backendForPath(jsonlPath).name,
4449
4749
  model: a?.model || rec?.model || "",
4750
+ effort: a?.effort || rec?.effort || "",
4450
4751
  tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
4451
4752
  contextTokens: lastContextTokens(jsonlPath),
4452
4753
  };
@@ -4605,7 +4906,8 @@ export const startMirror = (deps) => {
4605
4906
  ...allTargets().map(baseOfKey),
4606
4907
  baseOfKey(self),
4607
4908
  ]);
4608
- return [...bases].filter(Boolean).sort().map((base) => ({
4909
+ // 内部会话 (记忆整理者) 的 home 不是聊天: 不列、不补名。
4910
+ return [...bases].filter((b) => b && !isInternalKey(b)).sort().map((base) => ({
4609
4911
  base,
4610
4912
  name: chatNameOf(cfg, base),
4611
4913
  self: base === baseOfKey(self),
@@ -4633,18 +4935,27 @@ export const startMirror = (deps) => {
4633
4935
  return false;
4634
4936
  if (live.status !== "idle")
4635
4937
  return undefined;
4938
+ return !hanging(target, live.sessionId);
4939
+ };
4940
+ /** 闲着却悬着工具调用 = 停在审批卡 / 本地弹窗上。resume / fork 会换 sid: 悬着的
4941
+ * 调用记在注册表报的当前那份 transcript 里。 */
4942
+ const hanging = (target, sid) => {
4636
4943
  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);
4944
+ const jsonl = bound ? join(dirname(bound), `${sid}.jsonl`) : "";
4945
+ return !!jsonl && existsSync(jsonl) && openToolUses(jsonl).length > 0;
4946
+ };
4947
+ const parkedNow = (target) => {
4948
+ const pane = paneOf(target);
4949
+ const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
4950
+ return live?.status === "idle" && hanging(target, live.sessionId);
4640
4951
  };
4641
4952
  const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
4642
4953
  // 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
4643
4954
  // 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
4644
4955
  const IDLE_SAFETY_POLL_MS = 1000;
4645
- const untilIdle = (target, timeoutMs) => {
4956
+ const untilIdle = (target, timeoutMs, { aborted = () => false, confirm = 2 } = {}) => {
4646
4957
  if (idleVerdict(target) === undefined)
4647
- return waitForIdle(target, isBusy, timeoutMs, () => false, { rampMs: 0, confirm: 2 });
4958
+ return waitForIdle(target, isBusy, timeoutMs, aborted, { rampMs: 0, confirm });
4648
4959
  return new Promise((resolve) => {
4649
4960
  let done = false;
4650
4961
  const finish = (r) => {
@@ -4668,6 +4979,10 @@ export const startMirror = (deps) => {
4668
4979
  const tick = setInterval(() => void check(), IDLE_SAFETY_POLL_MS);
4669
4980
  const timer = setTimeout(() => finish({ idle: false, reason: "idle wait timed out" }), timeoutMs);
4670
4981
  void check();
4982
+ if (aborted()) {
4983
+ finish({ idle: false, reason: "stopped" });
4984
+ return;
4985
+ }
4671
4986
  });
4672
4987
  };
4673
4988
  const lastText = (target) => {
@@ -4678,9 +4993,9 @@ export const startMirror = (deps) => {
4678
4993
  const p = jsonlOf(target);
4679
4994
  return p ? lastReply(p, sinceMs, warmerSigs) : "";
4680
4995
  };
4681
- const replyToPeerSince = (target, fromName, sinceMs = 0) => {
4996
+ const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
4682
4997
  const p = jsonlOf(target);
4683
- return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs) : undefined;
4998
+ return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs, turn) : undefined;
4684
4999
  };
4685
5000
  /** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
4686
5001
  const paneLiveOf = async (target) => {
@@ -4841,7 +5156,7 @@ export const startMirror = (deps) => {
4841
5156
  round = s.streak;
4842
5157
  }
4843
5158
  catch { /* unreadable tail */ }
4844
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0 };
5159
+ return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), stallFree: 0 };
4845
5160
  };
4846
5161
  const fireKeepalive = async (a, stalled) => {
4847
5162
  const kc = cfg.wrc.mirror.keepalive;
@@ -4861,13 +5176,15 @@ export const startMirror = (deps) => {
4861
5176
  // the turn somehow never emits turn_end, so a later real turn is never muted;
4862
5177
  // it also closes the detail turn so it can't hang open in the chat timeline.
4863
5178
  rememberInject(text);
4864
- a.keepaliveQuiet = setTimeout(() => {
4865
- a.keepaliveQuiet = undefined;
4866
- if (a.keepaliveTurnId) {
4867
- recordTurnClose(a.keepaliveTurnId);
4868
- a.keepaliveTurnId = undefined;
4869
- }
4870
- }, 60_000);
5179
+ // 续跑不是保温 (见 isKeepalivePing): 不开吞没窗口, 也不记成保温轮。
5180
+ if (!stalled)
5181
+ a.keepaliveQuiet = setTimeout(() => {
5182
+ a.keepaliveQuiet = undefined;
5183
+ if (a.keepaliveTurnId) {
5184
+ recordTurnClose(a.keepaliveTurnId);
5185
+ a.keepaliveTurnId = undefined;
5186
+ }
5187
+ }, 60_000);
4871
5188
  const r = await inject({
4872
5189
  text, images: [], cfg,
4873
5190
  log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
@@ -4889,7 +5206,7 @@ export const startMirror = (deps) => {
4889
5206
  // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
4890
5207
  // The tail's keepalive_start (content match on the user line) may have
4891
5208
  // opened this turn already — only open when it hasn't.
4892
- if (!a.keepaliveTurnId) {
5209
+ if (!stalled && !a.keepaliveTurnId) {
4893
5210
  const turnId = newTurnId();
4894
5211
  a.keepaliveTurnId = turnId;
4895
5212
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
@@ -4902,8 +5219,9 @@ export const startMirror = (deps) => {
4902
5219
  return;
4903
5220
  keepaliveTicking = true;
4904
5221
  try {
4905
- const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
4906
- const ttlMs = kc.ttlSec * 1000;
5222
+ // Stall recovery keeps the configured window: a turn that died on an API
5223
+ // error should be resumed minutes later, whatever the cache tier.
5224
+ const stallQuietMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
4907
5225
  const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
4908
5226
  const now = Date.now();
4909
5227
  for (const a of byTarget.values()) {
@@ -4942,6 +5260,7 @@ export const startMirror = (deps) => {
4942
5260
  k.lastMs = mtime;
4943
5261
  k.lastRealMs = mtime;
4944
5262
  } // backend without timestamps → fall back to mtime
5263
+ k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
4945
5264
  }
4946
5265
  k.seenMtime = mtime;
4947
5266
  // Round reset / real-activity re-anchor keys off REAL turns only — a
@@ -4980,8 +5299,13 @@ export const startMirror = (deps) => {
4980
5299
  if (a.keepaliveOff)
4981
5300
  continue; // paused by /stop until real activity returns
4982
5301
  const idleSinceTouch = k.lastMs ? now - k.lastMs : now - mtime; // last model turn = cache touch
4983
- if (idleSinceTouch < idleTriggerMs)
4984
- continue; // cache still comfortably warm
5302
+ // The TTL the session's cache entries were actually written with (1h on a
5303
+ // Claude Code subscription), not a guess: pinging at the 5min cadence
5304
+ // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5305
+ const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5306
+ const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5307
+ if (idleSinceTouch < stallQuietMs)
5308
+ continue; // nothing due yet on either clock
4985
5309
  if (idleSinceTouch >= ttlMs)
4986
5310
  continue; // cache already cold — a ping would cold-rewrite for nothing
4987
5311
  if (k.round >= kc.rounds)
@@ -4990,8 +5314,15 @@ export const startMirror = (deps) => {
4990
5314
  // the transcript's structure only — never the screen: the turn is still
4991
5315
  // unfinished (died on a synthetic API error, or a tool result nobody
4992
5316
  // 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);
5317
+ // instruction instead of the plain warmer. A transcript found un-stalled a
5318
+ // minute past the quiet window stays so until a new turn lands (= lastMs
5319
+ // moves) — remembered, so a 1h idle stretch reads it once, not every tick.
5320
+ const stalled = kc.resumeOnStall && k.stallFree !== k.lastMs
5321
+ && transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), stallQuietMs, now);
5322
+ if (!stalled && idleSinceTouch >= stallQuietMs + 60_000)
5323
+ k.stallFree = k.lastMs;
5324
+ if (!stalled && idleSinceTouch < idleTriggerMs)
5325
+ continue; // cache still comfortably warm
4995
5326
  k.pinging = true;
4996
5327
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
4997
5328
  await fireKeepalive(a, stalled);
@@ -5150,6 +5481,7 @@ export const startMirror = (deps) => {
5150
5481
  peekTurns,
5151
5482
  isBusy,
5152
5483
  idleNow,
5484
+ parkedNow,
5153
5485
  untilIdle,
5154
5486
  setModel: async (target, wanted, scope) => {
5155
5487
  const a = byTarget.get(target);
@@ -5164,6 +5496,19 @@ export const startMirror = (deps) => {
5164
5496
  }
5165
5497
  return r;
5166
5498
  },
5499
+ setEffort: async (target, effort) => {
5500
+ const a = byTarget.get(target);
5501
+ if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5502
+ return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
5503
+ const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
5504
+ if (r.ok && r.applied) {
5505
+ a.effort = r.applied;
5506
+ const rec = deps.store.get(target);
5507
+ if (rec)
5508
+ deps.store.set(target, { ...rec, effort: r.applied });
5509
+ }
5510
+ return r;
5511
+ },
5167
5512
  transcripts: () => allTargets().flatMap((target) => {
5168
5513
  const jsonlPath = jsonlOf(target);
5169
5514
  if (!jsonlPath)
@@ -5368,15 +5713,8 @@ export const startMirror = (deps) => {
5368
5713
  rememberInject(full);
5369
5714
  // 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
5370
5715
  // 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;
5716
+ // 印章随这一行走 (见 Seal): 行露面时才落成那一轮的出处 / 频道 / 问话。
5717
+ pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
5380
5718
  const sid = a.sessionId;
5381
5719
  const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
5382
5720
  if (!paneAlive) {
@@ -5384,15 +5722,15 @@ export const startMirror = (deps) => {
5384
5722
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
5385
5723
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
5386
5724
  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 }) });
5725
+ 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
5726
  if (!r.ok || !r.tmuxPane)
5389
5727
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
5390
5728
  a.tmuxPane = r.tmuxPane;
5391
5729
  a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
5392
5730
  if (r.cwd)
5393
5731
  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);
5732
+ 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 });
5733
+ startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5396
5734
  }
5397
5735
  // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
5398
5736
  // is still warming up so the verifier in injectViaTmux needs the slack.
@@ -5507,6 +5845,27 @@ export const startMirror = (deps) => {
5507
5845
  return 0;
5508
5846
  }
5509
5847
  },
5848
+ reapUnborn: async (names) => {
5849
+ const want = new Set(names.flatMap((n) => [n, `.${n}`]));
5850
+ if (!want.size)
5851
+ return [];
5852
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}"]);
5853
+ if (snap.code !== 0)
5854
+ return [];
5855
+ const bound = new Set(allTargets().map(paneOf).filter(Boolean));
5856
+ const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
5857
+ .filter(([pane = "", session, win = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && !bound.has(pane))
5858
+ .map(([pane = ""]) => pane);
5859
+ const killed = [];
5860
+ for (const pane of doomed) {
5861
+ // 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
5862
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
5863
+ await sleep(250);
5864
+ if ((await runTmux(["kill-pane", "-t", pane])).code === 0)
5865
+ killed.push(pane);
5866
+ }
5867
+ return killed;
5868
+ },
5510
5869
  reapOverflow: async (max, skip) => {
5511
5870
  const now = Date.now();
5512
5871
  const reaped = [];
@@ -5613,6 +5972,13 @@ export const startMirror = (deps) => {
5613
5972
  log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
5614
5973
  return { ok: true };
5615
5974
  },
5975
+ retitlePane: async (target) => {
5976
+ const pane = paneOf(target);
5977
+ if (!pane)
5978
+ return { ok: false, reason: "no tmux pane bound for target" };
5979
+ const r = await renameWindow(pane, displayName(target) || target);
5980
+ return r.code === 0 ? { ok: true } : { ok: false, reason: `rename-window failed: ${r.stderr.trim() || r.code}` };
5981
+ },
5616
5982
  revealPane: async (target) => {
5617
5983
  // paneOf (not byTarget.get) so a cold binding surviving only in the
5618
5984
  // persisted store can still be revealed after a daemon reload.
@@ -5686,6 +6052,7 @@ export const startMirror = (deps) => {
5686
6052
  sessionInfo,
5687
6053
  setCharterProvider,
5688
6054
  onSpawn,
6055
+ onContextCut,
5689
6056
  shutdown: () => {
5690
6057
  clearInterval(paneDriftTimer);
5691
6058
  clearInterval(keepaliveTimer);
@@ -5742,7 +6109,9 @@ export const startMirror = (deps) => {
5742
6109
  }
5743
6110
  persistPause(a); // persist the pause/resume too, else a reload would revert it
5744
6111
  // 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
5745
- a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
6112
+ // brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
6113
+ if (!cfg.wrc.mirror.brief || isClearCommand(text))
6114
+ a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
5746
6115
  // Finalize prior live stream (if any) so this new turn renders into its
5747
6116
  // own message bubble. Then open a fresh stream tied to the new frame and
5748
6117
  // ack immediately so WeCom doesn't time out while inject queues.
@@ -5836,7 +6205,7 @@ export const startMirror = (deps) => {
5836
6205
  // 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
5837
6206
  // 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
5838
6207
  // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
5839
- await startBriefTurn(a, frame, streamId, isSlash, text);
6208
+ await startBriefTurn(a, frame, streamId, isSlash, text, channel ?? baseOfKey(principal), speaker);
5840
6209
  }
5841
6210
  else if (!armMigration && !eagerOpen) {
5842
6211
  enterDeferred(a, frame, streamId);
@@ -5871,7 +6240,7 @@ export const startMirror = (deps) => {
5871
6240
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
5872
6241
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
5873
6242
  // 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 }) });
6243
+ 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
6244
  if (r.ok && r.tmuxPane && r.tmuxSession) {
5876
6245
  a.tmuxPane = r.tmuxPane;
5877
6246
  a.tmuxSession = r.tmuxSession;
@@ -5885,6 +6254,7 @@ export const startMirror = (deps) => {
5885
6254
  tmuxPane: a.tmuxPane,
5886
6255
  cwd: a.runningCwd || undefined,
5887
6256
  model: a.model || undefined,
6257
+ effort: a.effort || undefined,
5888
6258
  pendingCwd: a.pendingCwd || undefined,
5889
6259
  });
5890
6260
  log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
@@ -5894,7 +6264,7 @@ export const startMirror = (deps) => {
5894
6264
  // file appears, the watcher times out harmlessly, and the existing
5895
6265
  // tail keeps working — safe under either behavior.
5896
6266
  if (resumeBaseline)
5897
- startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
6267
+ startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5898
6268
  }
5899
6269
  else {
5900
6270
  // Drop only the stale pane id; keep tmuxSession (if any) so the