wezard 2.0.4 → 2.0.5

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.
@@ -26,7 +26,7 @@ import { noticeSuffixFor } from "./notices.js";
26
26
  import { dangerOf } from "./danger.js";
27
27
  import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
28
28
  import { selectModel } from "./model-select.js";
29
- import { hasRegistry, markTranscript, probeOf, sessionOnPane, submittedSince } from "./cc-session.js";
29
+ import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince } from "./cc-session.js";
30
30
  import { wizardStore } from "./wizard.js";
31
31
  import { startSubagentWatch } from "./subagent-tail.js";
32
32
  import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
@@ -34,7 +34,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagH
34
34
  import { splitMarkdown } from "../shared/md-chunk.js";
35
35
  import { randomTip } from "./tips.js";
36
36
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
37
- import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
37
+ import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, tailTurns, renderDialog } from "./peers.js";
38
38
  // PATH augmentation: launchd / systemd start the daemon
39
39
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
40
40
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -1565,6 +1565,9 @@ const queues = new Map();
1565
1565
  * behind it forever. Well under brief's 350s bubble ceiling so the user still
1566
1566
  * gets an error bubble rather than a silent stream timeout. */
1567
1567
  const INJECT_JOB_TIMEOUT_MS = 90_000;
1568
+ /** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
1569
+ * 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
1570
+ const ORPHAN_GRACE_MS = 10 * 60_000;
1568
1571
  /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
1569
1572
  * tmux read), but detaching the chain means the NEXT message doesn't inherit
1570
1573
  * its deadlock. Pair with an injectGen bump so the zombie can't act when it
@@ -2830,6 +2833,7 @@ export const startMirror = (deps) => {
2830
2833
  // slower than the 60s fail-safe. Reuses the detail turn the timer flow may
2831
2834
  // have opened (fireKeepalive skips opening when this got there first).
2832
2835
  const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
2836
+ const PONG_RE = /pong/i;
2833
2837
  const beginKeepaliveByContent = (a, text) => {
2834
2838
  a.keepaliveByContent = true;
2835
2839
  if (a.keepaliveContentTimer)
@@ -2883,6 +2887,19 @@ export const startMirror = (deps) => {
2883
2887
  // keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
2884
2888
  if (item.kind === "skill_output" && item.agentDone)
2885
2889
  clearBackgroundAgent(a, item.agentDone);
2890
+ // Reply-side belt: a "pong" arriving with NO swallow holding. Both triggers
2891
+ // above live in memory, and the tail re-attaches at EOF — a reload between
2892
+ // the ping and its reply forgets the ping was ever sent, and the pong opens
2893
+ // a chat turn of its own. So ask the transcript: if the last thing said to
2894
+ // the model is a keepalive ping, this text is its pong — swallow from here
2895
+ // to the turn_end, recorded as the same marked detail turn. Gated on the
2896
+ // transcript, not on the word alone: a real answer that mentions pong has a
2897
+ // real question in front of it.
2898
+ if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
2899
+ const ping = openKeepalivePing(a.jsonlPath, keepalivePingSigs);
2900
+ if (ping)
2901
+ beginKeepaliveByContent(a, ping);
2902
+ }
2886
2903
  if (a.keepaliveQuiet || a.keepaliveByContent) {
2887
2904
  const id = a.keepaliveTurnId;
2888
2905
  if (id) {
@@ -5285,18 +5302,55 @@ export const startMirror = (deps) => {
5285
5302
  // 一次 list-panes 同时回答「活着吗」和「有人盯着吗」—— 跑了几个月的机器上
5286
5303
  // allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
5287
5304
  // WORLD_PROBE 取舍)。
5288
- const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}"]);
5305
+ const orphans = [];
5306
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{window_activity}"]);
5289
5307
  if (snap.code !== 0)
5290
- return { reaped, skipped };
5291
- const watchedBy = new Map(snap.stdout.split("\n").filter(Boolean).map((l) => {
5292
- const [id, w = ""] = l.split("\t");
5308
+ return { reaped, orphans, skipped };
5309
+ const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
5310
+ const watchedBy = new Map(rows.map(([id = "", w = ""]) => {
5293
5311
  const [wActive, sAttached] = w.split(":");
5294
5312
  return [id, wActive === "1" && sAttached !== "0"];
5295
5313
  }));
5296
- const live = allTargets().filter((t) => watchedBy.has(paneOf(t)));
5314
+ // 收一个 pane; 返回没收成的原因, 收掉了返回 undefined。
5315
+ const putDown = async (pane) => {
5316
+ // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5317
+ if (watchedBy.get(pane))
5318
+ return "watched";
5319
+ if (paneIsBusy(await capturePaneTail(pane, 12)))
5320
+ return "busy";
5321
+ // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5322
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
5323
+ await sleep(250);
5324
+ const r = await runTmux(["kill-pane", "-t", pane]);
5325
+ return r.code === 0 ? undefined : `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
5326
+ };
5327
+ const targets = allTargets();
5328
+ // 孤儿: 住在 daemon 自己的 tmux session 里、却没有任何绑定指着它的 pane —— 绑定
5329
+ // 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
5330
+ // 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
5331
+ // 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
5332
+ // 用 window_activity 留一段宽限。只收会话 pane: 注册表认得里面住着活的 AI 会话
5333
+ // 才算; 普通 pane (人自己开的 shell / 编辑器, 会话已退回 shell 的, 没有注册表的
5334
+ // 后端) 一律跳过 —— 认不出就不动。
5335
+ const bound = new Set(targets.map(paneOf).filter(Boolean));
5336
+ const hosting = new Set(activeBackends().flatMap((b) => [...sessionPanes(b.homeDir)]));
5337
+ for (const [pane = "", , session, activity] of rows) {
5338
+ if (session !== cfg.wrc.tmuxPrefix || bound.has(pane) || !hosting.has(pane))
5339
+ continue;
5340
+ if (now - Number(activity) * 1000 < ORPHAN_GRACE_MS)
5341
+ continue;
5342
+ const why = await putDown(pane);
5343
+ if (why) {
5344
+ skipped[pane] = why;
5345
+ continue;
5346
+ }
5347
+ orphans.push(pane);
5348
+ log.info({ pane, idleHours: Math.round((now - Number(activity) * 1000) / 3.6e6) }, "mirror pane cap — orphan pane killed (no binding)");
5349
+ }
5350
+ const live = targets.filter((t) => watchedBy.has(paneOf(t)));
5297
5351
  let over = live.length - max;
5298
5352
  if (over <= 0)
5299
- return { reaped, skipped };
5353
+ return { reaped, orphans, skipped };
5300
5354
  // 超了才 stat: 最久没动的先收。还没写过 transcript 的是刚起的 pane, 按"此刻"算。
5301
5355
  const mtimeOf = (target) => {
5302
5356
  try {
@@ -5318,21 +5372,9 @@ export const startMirror = (deps) => {
5318
5372
  continue;
5319
5373
  }
5320
5374
  const pane = paneOf(target);
5321
- // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5322
- if (watchedBy.get(pane)) {
5323
- skipped[target] = "watched";
5324
- continue;
5325
- }
5326
- if (paneIsBusy(await capturePaneTail(pane, 12))) {
5327
- skipped[target] = "busy";
5328
- continue;
5329
- }
5330
- // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5331
- await runTmux(["send-keys", "-t", pane, "Escape"]);
5332
- await sleep(250);
5333
- const r = await runTmux(["kill-pane", "-t", pane]);
5334
- if (r.code !== 0) {
5335
- skipped[target] = `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
5375
+ const why = await putDown(pane);
5376
+ if (why) {
5377
+ skipped[target] = why;
5336
5378
  continue;
5337
5379
  }
5338
5380
  if (a)
@@ -5342,7 +5384,7 @@ export const startMirror = (deps) => {
5342
5384
  over -= 1;
5343
5385
  log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6), max }, "mirror pane cap — pane killed, binding kept");
5344
5386
  }
5345
- return { reaped, skipped };
5387
+ return { reaped, orphans, skipped };
5346
5388
  },
5347
5389
  submitPane: async (target) => {
5348
5390
  const a = byTarget.get(target);