wezard 1.2.19 → 1.2.21

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.
@@ -21,9 +21,10 @@ import { isModalPane, parseModalOptions, pickModalAnswer } from "../shared/modal
21
21
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
22
22
  import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
23
23
  import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
24
- import { labelFor, tagOfKey, baseOfKey, withTagHeader } from "../shared/session-label.js";
24
+ import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader } from "../shared/session-label.js";
25
25
  import { splitMarkdown } from "../shared/md-chunk.js";
26
26
  import { randomTip } from "./tips.js";
27
+ import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
27
28
  import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
28
29
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
29
30
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
@@ -2116,73 +2117,39 @@ export const startMirror = (deps) => {
2116
2117
  clearTimeout(a.keepaliveQuiet);
2117
2118
  a.keepaliveQuiet = undefined;
2118
2119
  };
2119
- // A reply is still a heartbeat while its letters remain a prefix of "pong"
2120
- // (covers streamed partials "p"/"po"/"pon" and trailing punctuation). Anything
2121
- // else means the model ignored the ping and resumed real work.
2122
- const pongPrefix = (s) => "pong".startsWith(s.replace(/[^a-zA-Z]/g, "").toLowerCase());
2123
- // Stop swallowing the in-flight ping turn: close its heartbeat detail turn,
2124
- // lift the quiet window, and re-anchor the clocks to "real activity now" so the
2125
- // resumed work resets the round budget. The caller then falls through to the
2126
- // normal onItem path, letting this and the rest of the turn reach chat.
2127
- const releaseKeepalive = (a) => {
2128
- if (a.keepaliveTurnId) {
2129
- recordTurnClose(a.keepaliveTurnId);
2130
- a.keepaliveTurnId = undefined;
2131
- }
2132
- endKeepaliveQuiet(a);
2133
- a.keepalivePongBuf = undefined;
2134
- if (a.keepalive) {
2135
- a.keepalive.round = 0;
2136
- a.keepalive.pinging = false;
2137
- a.keepalive.settledAt = 0;
2138
- a.keepalive.lastRealMs = Date.now();
2139
- }
2140
- log.info({ target: a.target, sessionId: a.sessionId }, "keepalive: reply diverged from pong — un-swallowing resumed turn");
2141
- };
2120
+ // A keepalive ping turn is swallowed wholesale the reply content doesn't
2121
+ // matter (model may add extra commentary beyond "pong", that's fine).
2142
2122
  const onItem = (a, item) => {
2143
2123
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
2144
2124
  if (a.muteUntilInject)
2145
2125
  return;
2146
2126
  // Keepalive ping turns are cache-warmers: swallow every item from the WeCom
2147
2127
  // paths so the ping/pong never reaches chat. But record the REAL exchange
2148
- // into its chat-detail turn — the actual assistant reply (expected: just
2149
- // "pong"), the tool calls if any, and the usage (proof it was a cheap
2150
- // cache-read) — so the detail page shows the genuine heartbeat, not a
2151
- // synthetic summary. Closed on the terminal signal (hard or soft turn_end).
2128
+ // into its chat-detail turn — the actual assistant reply, the tool calls if
2129
+ // any, and the usage (proof it was a cheap cache-read) — so the detail page
2130
+ // shows the genuine heartbeat, not a synthetic summary.
2152
2131
  if (a.keepaliveQuiet) {
2153
- // Swallow only while the reply is consistent with a bare "pong". A text
2154
- // whose accumulated letters leave the "pong" prefix, or ANY tool_use (a
2155
- // heartbeat never calls tools), means the model resumed real work → release
2156
- // and fall through so the turn reaches chat instead of being hidden.
2157
- const nextBuf = item.kind === "text" ? (a.keepalivePongBuf ?? "") + item.body : (a.keepalivePongBuf ?? "");
2158
- const divergent = item.kind === "tool_use" || (item.kind === "text" && !pongPrefix(nextBuf));
2159
- if (!divergent) {
2160
- if (item.kind === "text")
2161
- a.keepalivePongBuf = nextBuf;
2162
- const id = a.keepaliveTurnId;
2163
- if (id) {
2164
- const now = Date.now();
2165
- if (item.kind === "text") {
2166
- recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
2167
- }
2168
- else if (item.kind === "tool_result") {
2169
- recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
2170
- }
2171
- else if (item.kind === "turn_usage") {
2172
- recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
2173
- }
2174
- else if (item.kind === "turn_end") {
2175
- recordTurnClose(id);
2176
- a.keepaliveTurnId = undefined;
2177
- }
2132
+ const id = a.keepaliveTurnId;
2133
+ if (id) {
2134
+ const now = Date.now();
2135
+ if (item.kind === "text") {
2136
+ recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
2178
2137
  }
2179
- if (item.kind === "turn_end") {
2180
- endKeepaliveQuiet(a);
2181
- a.keepalivePongBuf = undefined;
2138
+ else if (item.kind === "tool_result") {
2139
+ recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
2182
2140
  }
2183
- return;
2141
+ else if (item.kind === "turn_usage") {
2142
+ recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
2143
+ }
2144
+ else if (item.kind === "turn_end") {
2145
+ recordTurnClose(id);
2146
+ a.keepaliveTurnId = undefined;
2147
+ }
2148
+ }
2149
+ if (item.kind === "turn_end") {
2150
+ endKeepaliveQuiet(a);
2184
2151
  }
2185
- releaseKeepalive(a); // fall through to normal handling for this item
2152
+ return;
2186
2153
  }
2187
2154
  // 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
2188
2155
  // 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
@@ -3420,10 +3387,95 @@ export const startMirror = (deps) => {
3420
3387
  // untagged default plus every `#tag`. Live attachments are the truth; the
3421
3388
  // persisted store fills in cold bindings so a peer nobody has talked to since
3422
3389
  // the last reload is still discoverable (and revivable) rather than invisible.
3390
+ const allTargets = () => {
3391
+ const keys = new Set([...byTarget.keys(), ...Object.keys(deps.store.all())]);
3392
+ return Array.from(keys).sort();
3393
+ };
3423
3394
  const chatTargets = (target) => {
3424
3395
  const base = basePrincipalOf(target);
3425
- const keys = new Set([...byTarget.keys(), ...Object.keys(deps.store.all())]);
3426
- return Array.from(keys).filter((k) => basePrincipalOf(k) === base).sort();
3396
+ return allTargets().filter((k) => basePrincipalOf(k) === base);
3397
+ };
3398
+ // 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
3399
+ // 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
3400
+ const resolveChatRef = (ref) => {
3401
+ const base = chatBaseOf(cfg, ref);
3402
+ if (base)
3403
+ return { ok: true, base };
3404
+ const known = listChatNames(cfg).map((c) => c.name);
3405
+ return {
3406
+ ok: false,
3407
+ reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
3408
+ candidates: known,
3409
+ };
3410
+ };
3411
+ // peer 寻址。地址是两级的(见 chat-name.ts):
3412
+ // "" 本 chat 的 default —— 自身语义不变;
3413
+ // `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
3414
+ // 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
3415
+ // `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
3416
+ // `daily#` daily 的 default 会话;
3417
+ // `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
3418
+ // 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
3419
+ // 的全称地址"——后者不需要动别人的会话。
3420
+ const resolvePeerTag = (self, ref) => {
3421
+ const { chat, tag } = parsePeerRef(ref ?? "");
3422
+ const t = tag.trim();
3423
+ if (chat) {
3424
+ const c = resolveChatRef(chat);
3425
+ if (!c.ok)
3426
+ return c;
3427
+ const target = keyOf(c.base, t);
3428
+ if (allTargets().includes(target)) {
3429
+ return { ok: true, target, foreign: c.base !== basePrincipalOf(self) };
3430
+ }
3431
+ return {
3432
+ ok: false,
3433
+ reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
3434
+ candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base),
3435
+ };
3436
+ }
3437
+ const local = keyOf(basePrincipalOf(self), t);
3438
+ if (!t)
3439
+ return { ok: true, target: local, foreign: false };
3440
+ const all = allTargets();
3441
+ if (all.includes(local))
3442
+ return { ok: true, target: local, foreign: false };
3443
+ const foreignMatches = all.filter((k) => tagOfKey(k) === t && basePrincipalOf(k) !== basePrincipalOf(self));
3444
+ if (foreignMatches.length === 1)
3445
+ return { ok: true, target: foreignMatches[0], foreign: true };
3446
+ if (foreignMatches.length === 0) {
3447
+ // 裸 token 正好是个 chat 名字 —— 用户/agent 想说的是"那个群",不是"那个 tag"。
3448
+ // 直接给出它的会话地址,比让人再查一次 list_chats 快。
3449
+ const asChat = chatBaseOf(cfg, t);
3450
+ if (asChat) {
3451
+ return {
3452
+ ok: false,
3453
+ reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
3454
+ candidates: allTargets().filter((k) => basePrincipalOf(k) === asChat).map((k) => peerAddress(cfg, self, k)),
3455
+ };
3456
+ }
3457
+ return { ok: false, reason: `no peer with tag '#${t}' — create one with new_claude_session, or address it in full as 'chatName#${t}' (list_chats shows the names)` };
3458
+ }
3459
+ return {
3460
+ ok: false,
3461
+ reason: `tag '#${t}' exists in ${foreignMatches.length} chats — address it in full as 'chatName#${t}' (list_chats shows the names)`,
3462
+ candidates: foreignMatches.map((k) => peerAddress(cfg, self, k)),
3463
+ };
3464
+ };
3465
+ /** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
3466
+ * 它暂时只能靠全局唯一 tag 被找到的原因。 */
3467
+ const chatRoster = (self) => {
3468
+ const bases = new Set([
3469
+ ...listChatNames(cfg).map((c) => c.base),
3470
+ ...allTargets().map(basePrincipalOf),
3471
+ basePrincipalOf(self),
3472
+ ]);
3473
+ return [...bases].filter(Boolean).sort().map((base) => ({
3474
+ base,
3475
+ name: chatNameOf(cfg, base),
3476
+ self: base === basePrincipalOf(self),
3477
+ targets: allTargets().filter((k) => basePrincipalOf(k) === base),
3478
+ }));
3427
3479
  };
3428
3480
  const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
3429
3481
  const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
@@ -3436,38 +3488,52 @@ export const startMirror = (deps) => {
3436
3488
  const p = jsonlOf(target);
3437
3489
  return p ? lastAssistantText(p) : "";
3438
3490
  };
3439
- const peers = async (target) => {
3440
- const list = await Promise.all(chatTargets(target).map(async (t) => {
3441
- const a = byTarget.get(t);
3442
- const rec = deps.store.get(t);
3443
- const jsonlPath = jsonlOf(t);
3444
- const pane = paneOf(t);
3445
- const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
3446
- const tag = tagOfTarget(t);
3447
- let lastActivity = 0;
3448
- try {
3449
- if (jsonlPath)
3450
- lastActivity = statSync(jsonlPath).mtimeMs;
3451
- }
3452
- catch { /* not written yet */ }
3453
- return {
3454
- target: t,
3455
- tag,
3456
- label: tag ? labelFor(tag) : "🧙",
3457
- sessionId: a?.sessionId || rec?.sessionId || "",
3458
- jsonlPath,
3459
- cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
3460
- cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
3461
- tmuxPane: pane,
3462
- attached: !!a,
3463
- paneAlive,
3464
- busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
3465
- lastActivity,
3466
- summary: jsonlPath ? summarizeTail(jsonlPath) : "(未绑定会话)",
3467
- self: t === target,
3468
- };
3469
- }));
3470
- return list.sort((x, y) => y.lastActivity - x.lastActivity);
3491
+ // 一个 target 的完整画像。本 chat 的兄弟和外 chat 的 peer 走同一条,只有
3492
+ // `self` / `address` 因观察者而异 —— 地址本来就是相对调用方说的话。
3493
+ const peerInfoOf = async (t, self) => {
3494
+ const a = byTarget.get(t);
3495
+ const rec = deps.store.get(t);
3496
+ const jsonlPath = jsonlOf(t);
3497
+ const pane = paneOf(t);
3498
+ const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
3499
+ const tag = tagOfTarget(t);
3500
+ let lastActivity = 0;
3501
+ try {
3502
+ if (jsonlPath)
3503
+ lastActivity = statSync(jsonlPath).mtimeMs;
3504
+ }
3505
+ catch { /* not written yet */ }
3506
+ return {
3507
+ target: t,
3508
+ tag,
3509
+ chat: chatNameOf(cfg, t),
3510
+ address: peerAddress(cfg, self, t),
3511
+ label: tag ? labelFor(tag) : "🧙",
3512
+ sessionId: a?.sessionId || rec?.sessionId || "",
3513
+ jsonlPath,
3514
+ cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
3515
+ cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
3516
+ tmuxPane: pane,
3517
+ attached: !!a,
3518
+ paneAlive,
3519
+ busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
3520
+ lastActivity,
3521
+ summary: jsonlPath ? summarizeTail(jsonlPath) : "(未绑定会话)",
3522
+ self: t === self,
3523
+ };
3524
+ };
3525
+ const byRecent = (x, y) => y.lastActivity - x.lastActivity;
3526
+ const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
3527
+ // list_peers 的跨 chat 补充:其他 chat 里**当前调用方叫得动**的 session。两条
3528
+ // 入选路径 —— tag 全局唯一(裸 tag 就能命中),或者它所在的 chat 有名字(全称
3529
+ // `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
3530
+ // 只要给群起个名,不必回去改别人的 tag。
3531
+ const foreignPeers = async (self) => {
3532
+ const selfBase = basePrincipalOf(self);
3533
+ const foreign = allTargets().filter((k) => tagOfKey(k) && basePrincipalOf(k) !== selfBase);
3534
+ const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
3535
+ const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
3536
+ return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
3471
3537
  };
3472
3538
  // Capture a few extra rows then compact away the TUI's blank padding, so
3473
3539
  // `rows` counts lines the user actually cares about.
@@ -3533,7 +3599,6 @@ export const startMirror = (deps) => {
3533
3599
  // buys a deterministic reply. keepaliveStamps matches every form, so none
3534
3600
  // reads as real activity. (round is incremented below, after the inject lands.)
3535
3601
  const text = stalled ? kc.resumePing : kc.ping;
3536
- a.keepalivePongBuf = ""; // fresh reply accumulator for the swallow/release gate
3537
3602
  // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
3538
3603
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
3539
3604
  // the turn somehow never emits turn_end, so a later real turn is never muted;
@@ -3678,7 +3743,11 @@ export const startMirror = (deps) => {
3678
3743
  const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
3679
3744
  return {
3680
3745
  attach,
3746
+ chatTargets,
3681
3747
  peers,
3748
+ foreignPeers,
3749
+ resolvePeerTag,
3750
+ chatRoster,
3682
3751
  peekPane,
3683
3752
  peekTurns,
3684
3753
  isBusy,