wezard 2.0.1 → 2.0.3

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.
@@ -1655,6 +1655,9 @@ export const startMirror = (deps) => {
1655
1655
  // for >60s mid-turn and we don't want to drop the bubble while it's chewing.
1656
1656
  const FLUSH_MS = 250;
1657
1657
  const HARD_TIMEOUT_MS = 350_000;
1658
+ /** hard cap 到点时的中间结束语: 气泡必须 finish=true, 但 turn 还在跑 —— 明说
1659
+ * 「没完, 去链接看」, 而不是让气泡停在一句看似说完的话 / 一行过期的进度上。 */
1660
+ const STILL_WORKING = "⏳ 仍在处理中, 点击链接查看详情…";
1658
1661
  /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1659
1662
  * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1660
1663
  * 这一段, 与模型思考/工具执行时长无关。
@@ -1721,7 +1724,7 @@ export const startMirror = (deps) => {
1721
1724
  return;
1722
1725
  s.flushTimer = setTimeout(() => void flushStream(s), FLUSH_MS);
1723
1726
  };
1724
- const finalizeStream = async (a, s) => {
1727
+ const finalizeStream = async (a, s, timedOut = false) => {
1725
1728
  if (s.closed)
1726
1729
  return;
1727
1730
  s.closed = true;
@@ -1743,17 +1746,20 @@ export const startMirror = (deps) => {
1743
1746
  // 没正文不写: finish=true 是整条覆盖, 空正文只会把 "…" ack 覆盖成光秃秃的
1744
1747
  // 链接头, 结束处理自己制造错发。气泡保持现有内容, 由 WeCom 6min 窗口自然
1745
1748
  // 到期。dead 流同理只做本地清理。
1746
- if (!s.dead && s.acc.trim()) {
1749
+ // 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
1750
+ // 也有话可说, 不算空覆盖。
1751
+ const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
1752
+ if (!s.dead && body.trim()) {
1747
1753
  const card = detailCardFor(s, a.target);
1748
1754
  try {
1749
1755
  if (card) {
1750
1756
  s.cardSent = true;
1751
- await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, s.acc, undefined, s.turnId), true, { templateCard: card });
1757
+ await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
1752
1758
  }
1753
1759
  else {
1754
- await client.replyStream(s.frame, s.streamId, withLinkedTag(a, s.acc, undefined, s.turnId), true);
1760
+ await client.replyStream(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true);
1755
1761
  }
1756
- log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
1762
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card, timedOut }, "stream finalize");
1757
1763
  }
1758
1764
  catch (e) {
1759
1765
  log.warn({ sessionId: a.sessionId, turnId: s.turnId, err: e.message }, "stream finalize failed");
@@ -1783,7 +1789,7 @@ export const startMirror = (deps) => {
1783
1789
  capped: false, closed: false, dead: false, cardSent: false,
1784
1790
  tools: [], sawTool: false,
1785
1791
  };
1786
- s.hardTimer = setTimeout(() => void finalizeStream(a, s), HARD_TIMEOUT_MS);
1792
+ s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
1787
1793
  log.info({ sessionId: a.sessionId, turnId: s.turnId, streamId }, "stream open");
1788
1794
  return s;
1789
1795
  };
@@ -2261,11 +2267,11 @@ export const startMirror = (deps) => {
2261
2267
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2262
2268
  const q = { turnId, bubble, isSlash };
2263
2269
  bubble.hardTimer = setTimeout(() => {
2264
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。没正文时不能拿光链接
2265
- // 覆盖 —— finish 是整条替换, 会把屏幕上的 CoT 进度行抹成光秃秃的链接; 改用
2266
- // 最新进度行定格 (a 上的 CoT 只在气泡仍是本轮活跃气泡时才可信, 换轮后归新轮)。
2267
- const cot = a.briefBubble === bubble && !a.briefConcluded ? a.cotText ?? a.cotLastSent : undefined;
2268
- void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${cot ? ` \`${cot}\`` : ""}`, true);
2270
+ // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
2271
+ // 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
2272
+ // 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
2273
+ const live = a.briefBubble === bubble && !a.briefConcluded;
2274
+ void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
2269
2275
  }, HARD_TIMEOUT_MS);
2270
2276
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
2271
2277
  // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
@@ -4023,6 +4029,9 @@ export const startMirror = (deps) => {
4023
4029
  // 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
4024
4030
  let charterOf;
4025
4031
  const setCharterProvider = (fn) => { charterOf = fn; };
4032
+ // 新 pane 落地后的钩子 —— pane 上限在这里数 (由 index.ts 装上; 没装 = 不数)。
4033
+ let afterSpawn = () => { };
4034
+ const onSpawn = (fn) => { afterSpawn = fn; };
4026
4035
  // `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
4027
4036
  // 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
4028
4037
  // 旧工作区里。
@@ -4143,6 +4152,7 @@ export const startMirror = (deps) => {
4143
4152
  }
4144
4153
  if (!opts?.silent)
4145
4154
  pushProjectInfo(target, "created");
4155
+ afterSpawn();
4146
4156
  return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
4147
4157
  };
4148
4158
  // ── Clone ───────────────────────────────────────────────────────────
@@ -4253,6 +4263,7 @@ export const startMirror = (deps) => {
4253
4263
  persistPause(spawned);
4254
4264
  }
4255
4265
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4266
+ afterSpawn();
4256
4267
  return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
4257
4268
  };
4258
4269
  /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
@@ -5209,45 +5220,64 @@ export const startMirror = (deps) => {
5209
5220
  log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
5210
5221
  return { ok: true };
5211
5222
  },
5212
- reapIdle: async (ttlMs, skip) => {
5223
+ lastActivity: (target) => {
5224
+ const jsonl = jsonlOf(target);
5225
+ try {
5226
+ return jsonl ? statSync(jsonl).mtimeMs : 0;
5227
+ }
5228
+ catch {
5229
+ return 0;
5230
+ }
5231
+ },
5232
+ reapOverflow: async (max, skip) => {
5213
5233
  const now = Date.now();
5214
5234
  const reaped = [];
5215
5235
  const skipped = {};
5216
- for (const target of allTargets()) {
5217
- const a = byTarget.get(target);
5218
- const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
5219
- // 便宜闸门先行: transcript mtime 一次 statSync, 不 spawn —— 跑了几个
5220
- // 月的机器上 allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd
5221
- // (同 worldPeers 的 WORLD_PROBE 取舍)。还没写过 transcript 的 spawn-mode
5222
- // 绑定没有活动证据, 不动它。
5223
- const jsonl = jsonlOf(target);
5224
- let mtime = 0;
5236
+ // 一次 list-panes 同时回答「活着吗」和「有人盯着吗」—— 跑了几个月的机器上
5237
+ // allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
5238
+ // WORLD_PROBE 取舍)。
5239
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}"]);
5240
+ if (snap.code !== 0)
5241
+ return { reaped, skipped };
5242
+ const watchedBy = new Map(snap.stdout.split("\n").filter(Boolean).map((l) => {
5243
+ const [id, w = ""] = l.split("\t");
5244
+ const [wActive, sAttached] = w.split(":");
5245
+ return [id, wActive === "1" && sAttached !== "0"];
5246
+ }));
5247
+ const live = allTargets().filter((t) => watchedBy.has(paneOf(t)));
5248
+ let over = live.length - max;
5249
+ if (over <= 0)
5250
+ return { reaped, skipped };
5251
+ // 超了才 stat: 最久没动的先收。还没写过 transcript 的是刚起的 pane, 按"此刻"算。
5252
+ const mtimeOf = (target) => {
5225
5253
  try {
5226
- if (jsonl)
5227
- mtime = statSync(jsonl).mtimeMs;
5254
+ return statSync(jsonlOf(target)).mtimeMs;
5228
5255
  }
5229
- catch { /* 文件没了 */ }
5230
- if (!mtime || now - mtime < ttlMs)
5231
- continue;
5256
+ catch {
5257
+ return now;
5258
+ }
5259
+ };
5260
+ const oldestFirst = live.map((target) => ({ target, mtime: mtimeOf(target) })).sort((x, y) => x.mtime - y.mtime);
5261
+ for (const { target, mtime } of oldestFirst) {
5262
+ if (over <= 0)
5263
+ break;
5264
+ const a = byTarget.get(target);
5265
+ const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
5232
5266
  const veto = skip?.(target, sessionId);
5233
5267
  if (veto) {
5234
5268
  skipped[target] = veto;
5235
5269
  continue;
5236
5270
  }
5237
5271
  const pane = paneOf(target);
5238
- if (!pane || !(await tmuxPaneAlive(pane)))
5239
- continue; // pane 已死, 没什么可收的
5240
- if (paneIsBusy(await capturePaneTail(pane, 12))) {
5241
- skipped[target] = "busy";
5242
- continue;
5243
- }
5244
5272
  // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5245
- const w = await runTmux(["display-message", "-p", "-t", pane, "#{window_active}:#{session_attached}"]);
5246
- const [wActive, sAttached] = w.stdout.trim().split(":");
5247
- if (wActive === "1" && sAttached !== "0") {
5273
+ if (watchedBy.get(pane)) {
5248
5274
  skipped[target] = "watched";
5249
5275
  continue;
5250
5276
  }
5277
+ if (paneIsBusy(await capturePaneTail(pane, 12))) {
5278
+ skipped[target] = "busy";
5279
+ continue;
5280
+ }
5251
5281
  // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5252
5282
  await runTmux(["send-keys", "-t", pane, "Escape"]);
5253
5283
  await sleep(250);
@@ -5257,10 +5287,11 @@ export const startMirror = (deps) => {
5257
5287
  continue;
5258
5288
  }
5259
5289
  if (a)
5260
- detach(a, "idle reap");
5290
+ detach(a, "pane cap");
5261
5291
  // 不碰 store —— 绑定留着, 下一条 inbound 走 dead-pane `--resume` 复活。
5262
5292
  reaped.push(target);
5263
- log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6) }, "mirror idle reap — pane killed, binding kept");
5293
+ over -= 1;
5294
+ log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6), max }, "mirror pane cap — pane killed, binding kept");
5264
5295
  }
5265
5296
  return { reaped, skipped };
5266
5297
  },
@@ -5351,6 +5382,7 @@ export const startMirror = (deps) => {
5351
5382
  cloneSession,
5352
5383
  sessionInfo,
5353
5384
  setCharterProvider,
5385
+ onSpawn,
5354
5386
  shutdown: () => {
5355
5387
  clearInterval(paneDriftTimer);
5356
5388
  clearInterval(keepaliveTimer);