wezard 1.2.19 → 1.2.22
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.
- package/README.md +42 -0
- package/dist/daemon/approval.js +13 -8
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-name.js +86 -0
- package/dist/daemon/chat-name.js.map +1 -0
- package/dist/daemon/inbound.js +157 -10
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +177 -48
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +356 -107
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +97 -7
- package/dist/daemon/peers.js.map +1 -1
- package/dist/mcp/server.js +60 -26
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/cli-backends.js +23 -2
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +10 -0
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/session-label.js +65 -5
- package/dist/shared/session-label.js.map +1 -1
- package/launchd/com.wezard.daemon.plist.template +0 -2
- package/package.json +1 -1
|
@@ -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).
|
|
@@ -353,6 +354,20 @@ const oneLineSummary = (s, max = 40) => {
|
|
|
353
354
|
const flat = s.replace(/\s+/g, " ").trim();
|
|
354
355
|
return truncate(flat, max);
|
|
355
356
|
};
|
|
357
|
+
// think-style 收口去重: 软后端最终正文会先作为非 final 文本进 briefThinking, 收口
|
|
358
|
+
// 再当正文用一次。剥掉 think 尾部恰好等于 body 的那段 (含其前的 \n\n 分隔), 让正文
|
|
359
|
+
// 只出现在 </think> 之后。只剥完整尾段, 中途出现过的同句子片段不动 —— 那是真的思考。
|
|
360
|
+
const stripTrailingBody = (think, body) => {
|
|
361
|
+
if (!think || !body)
|
|
362
|
+
return think || undefined;
|
|
363
|
+
if (think === body)
|
|
364
|
+
return undefined;
|
|
365
|
+
if (think.endsWith(body)) {
|
|
366
|
+
const cut = think.slice(0, think.length - body.length).replace(/\n{2,}$/, "").trimEnd();
|
|
367
|
+
return cut || undefined;
|
|
368
|
+
}
|
|
369
|
+
return think;
|
|
370
|
+
};
|
|
356
371
|
// WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
|
|
357
372
|
// code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
|
|
358
373
|
// `<noreply@x>` etc. silently swallows the rest of the line plus the closing
|
|
@@ -563,7 +578,14 @@ const renderLine = (raw, deps) => {
|
|
|
563
578
|
flushPending();
|
|
564
579
|
pending.push({ toolUseId, name, input: b.input });
|
|
565
580
|
}
|
|
566
|
-
|
|
581
|
+
else if (b?.type === "thinking" && deps.thinkStyle) {
|
|
582
|
+
flushPending();
|
|
583
|
+
const raw = typeof b.thinking === "string" ? b.thinking : typeof b.text === "string" ? b.text : "";
|
|
584
|
+
const t = raw.trim();
|
|
585
|
+
if (t)
|
|
586
|
+
out.push({ kind: "thinking", body: t });
|
|
587
|
+
}
|
|
588
|
+
// thinking blocks otherwise intentionally skipped (只进详情页)
|
|
567
589
|
}
|
|
568
590
|
flushPending();
|
|
569
591
|
// Emit per-line usage snapshot BEFORE turn_end so brief store gets the last
|
|
@@ -1262,6 +1284,8 @@ const enqueue = (key, job) => {
|
|
|
1262
1284
|
};
|
|
1263
1285
|
export const startMirror = (deps) => {
|
|
1264
1286
|
const { cfg, log, client } = deps;
|
|
1287
|
+
// think-style: brief 模式下把 thinking 直接流进气泡, 且全程不下发任何详情链接。
|
|
1288
|
+
const thinkStyle = cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief;
|
|
1265
1289
|
// Multi-mirror: each (sessionId, target) pair is one Attachment. Same
|
|
1266
1290
|
// sessionId reattaches → replace. Same target with different sessionId →
|
|
1267
1291
|
// replace too (one WeCom chat can only show one mirror at a time).
|
|
@@ -1462,6 +1486,8 @@ export const startMirror = (deps) => {
|
|
|
1462
1486
|
// Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
|
|
1463
1487
|
// plain withSessionTag when no turnId is available (no active turn to link).
|
|
1464
1488
|
const linkedTagPrefix = (target, turnId) => {
|
|
1489
|
+
if (thinkStyle)
|
|
1490
|
+
return ""; // think-style: 全程不带详情链接, 一律退回 withSessionTag。
|
|
1465
1491
|
if (!turnId)
|
|
1466
1492
|
return "";
|
|
1467
1493
|
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
@@ -1768,6 +1794,63 @@ export const startMirror = (deps) => {
|
|
|
1768
1794
|
}
|
|
1769
1795
|
};
|
|
1770
1796
|
const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
|
|
1797
|
+
// think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
|
|
1798
|
+
const thinkTag = (target) => {
|
|
1799
|
+
const tag = tagOfKey(target);
|
|
1800
|
+
return tag ? `${labelFor(tag)} #${tag}` : "🧙";
|
|
1801
|
+
};
|
|
1802
|
+
const openThink = (target, think) => `<think>${thinkTag(target)} ${think}`;
|
|
1803
|
+
// think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
|
|
1804
|
+
// (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
|
|
1805
|
+
// raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
|
|
1806
|
+
const streamThink = (a) => {
|
|
1807
|
+
const b = a.briefBubble;
|
|
1808
|
+
const think = a.briefThinking?.trim();
|
|
1809
|
+
if (!b || b.done || !think)
|
|
1810
|
+
return;
|
|
1811
|
+
client
|
|
1812
|
+
.replyStream(b.frame, b.streamId, openThink(a.target, think), false)
|
|
1813
|
+
.catch((e) => log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed"));
|
|
1814
|
+
};
|
|
1815
|
+
// think-style 且气泡已过窗口 (b.done / 无气泡): 攒 think 增量, 10s 防抖后整段补发
|
|
1816
|
+
// 一条 `<think>🧙 …</think>` standalone —— 窗口外没法再刷气泡, 拉长防抖免刷屏。
|
|
1817
|
+
const THINK_STANDALONE_DEBOUNCE_MS = 10_000;
|
|
1818
|
+
const flushThinkStandalone = (a) => {
|
|
1819
|
+
const p = a.briefThinkPending;
|
|
1820
|
+
if (!p)
|
|
1821
|
+
return;
|
|
1822
|
+
a.briefThinkPending = undefined;
|
|
1823
|
+
const chunk = p.parts.join("\n\n").trim();
|
|
1824
|
+
if (chunk)
|
|
1825
|
+
sendRaw(a, `${openThink(a.target, chunk)}</think>`);
|
|
1826
|
+
};
|
|
1827
|
+
const enqueueThinkStandalone = (a, chunk) => {
|
|
1828
|
+
if (a.briefThinkPending) {
|
|
1829
|
+
clearTimeout(a.briefThinkPending.timer);
|
|
1830
|
+
a.briefThinkPending.parts.push(chunk);
|
|
1831
|
+
a.briefThinkPending.timer = setTimeout(() => flushThinkStandalone(a), THINK_STANDALONE_DEBOUNCE_MS);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
a.briefThinkPending = { parts: [chunk], timer: setTimeout(() => flushThinkStandalone(a), THINK_STANDALONE_DEBOUNCE_MS) };
|
|
1835
|
+
};
|
|
1836
|
+
const clearThinkPending = (a) => {
|
|
1837
|
+
if (a.briefThinkPending) {
|
|
1838
|
+
clearTimeout(a.briefThinkPending.timer);
|
|
1839
|
+
a.briefThinkPending = undefined;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
// think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
|
|
1843
|
+
// 气泡还活 → 累积进 briefThinking 并全量刷进气泡; 已过窗口 → 走 10s 防抖 standalone。
|
|
1844
|
+
const pushThink = (a, chunk) => {
|
|
1845
|
+
const c = chunk.trim();
|
|
1846
|
+
if (!c)
|
|
1847
|
+
return;
|
|
1848
|
+
a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
|
|
1849
|
+
if (a.briefBubble && !a.briefBubble.done)
|
|
1850
|
+
streamThink(a);
|
|
1851
|
+
else
|
|
1852
|
+
enqueueThinkStandalone(a, c);
|
|
1853
|
+
};
|
|
1771
1854
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
1772
1855
|
const openBriefTurn = (a, q) => {
|
|
1773
1856
|
a.briefTurnId = q.turnId;
|
|
@@ -1776,6 +1859,8 @@ export const startMirror = (deps) => {
|
|
|
1776
1859
|
a.briefHadTool = false;
|
|
1777
1860
|
a.briefConcluded = false;
|
|
1778
1861
|
a.briefLastText = undefined;
|
|
1862
|
+
a.briefThinking = undefined;
|
|
1863
|
+
clearThinkPending(a);
|
|
1779
1864
|
log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
|
|
1780
1865
|
};
|
|
1781
1866
|
// WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
|
|
@@ -1810,16 +1895,25 @@ export const startMirror = (deps) => {
|
|
|
1810
1895
|
log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
|
|
1811
1896
|
closeBriefTurn(a);
|
|
1812
1897
|
}
|
|
1813
|
-
|
|
1898
|
+
// think-style: 到点也不发链接, 有累积 thinking 就收进气泡 (tag 在 <think> 内), 否则空收。
|
|
1899
|
+
if (thinkStyle) {
|
|
1900
|
+
const think = a.briefThinking?.trim();
|
|
1901
|
+
void finishBubble(a, bubble, think ? `${openThink(a.target, think)}</think>` : withSessionTag(a.target, " "), true);
|
|
1902
|
+
}
|
|
1903
|
+
else {
|
|
1904
|
+
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
1905
|
+
}
|
|
1814
1906
|
}, HARD_TIMEOUT_MS);
|
|
1815
1907
|
// earlyTimer: 3s 无 CLI 产出 → 先把 loading 气泡收成详情链接, 用户可即时点入。
|
|
1816
1908
|
// 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
|
|
1817
1909
|
// 因为排队中的 turn 还没成为活跃 turn, a.briefBubble 指向的是前一个。
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1910
|
+
// think-style: 不设 earlyTimer —— 不发链接, 气泡留着等 thinking / 答复流进来。
|
|
1911
|
+
if (!thinkStyle)
|
|
1912
|
+
bubble.earlyTimer = setTimeout(() => {
|
|
1913
|
+
if (bubble.done)
|
|
1914
|
+
return;
|
|
1915
|
+
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
1916
|
+
}, EARLY_LINK_MS);
|
|
1823
1917
|
if (a.briefTurnId) {
|
|
1824
1918
|
(a.briefQueue ??= []).push(q);
|
|
1825
1919
|
log.info({ sessionId: a.sessionId, turnId, queued: a.briefQueue.length }, "brief: turn queued (previous still running)");
|
|
@@ -1828,7 +1922,14 @@ export const startMirror = (deps) => {
|
|
|
1828
1922
|
openBriefTurn(a, q);
|
|
1829
1923
|
}
|
|
1830
1924
|
try {
|
|
1831
|
-
|
|
1925
|
+
// think-style: 用户一发消息立刻回 `<think>🧙 ` 开头 (tag 在 <think> 内), 不再先占位 `…`。
|
|
1926
|
+
// 后续 thinking / 中途文本经 streamThink 全量替换续写, 收口时补 `</think>\n\n<final>`。
|
|
1927
|
+
if (thinkStyle) {
|
|
1928
|
+
await client.replyStream(frame, streamId, openThink(a.target, ""), false);
|
|
1929
|
+
}
|
|
1930
|
+
else {
|
|
1931
|
+
await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
|
|
1932
|
+
}
|
|
1832
1933
|
}
|
|
1833
1934
|
catch (e) {
|
|
1834
1935
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
@@ -1851,7 +1952,11 @@ export const startMirror = (deps) => {
|
|
|
1851
1952
|
a.briefHadTool = false;
|
|
1852
1953
|
a.briefConcluded = false;
|
|
1853
1954
|
a.briefLastText = undefined;
|
|
1854
|
-
|
|
1955
|
+
a.briefThinking = undefined;
|
|
1956
|
+
clearThinkPending(a);
|
|
1957
|
+
// think-style: 不推详情链接 (CLI 侧发起的无气泡 turn 无处可流, 保持静默; 最终答复走 standalone)。
|
|
1958
|
+
if (!thinkStyle)
|
|
1959
|
+
sendRaw(a, briefDetailLink(turnId, a.target));
|
|
1855
1960
|
log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
|
|
1856
1961
|
};
|
|
1857
1962
|
// 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
|
|
@@ -1861,6 +1966,11 @@ export const startMirror = (deps) => {
|
|
|
1861
1966
|
const earlyLinkBubble = (a) => {
|
|
1862
1967
|
if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
|
|
1863
1968
|
return;
|
|
1969
|
+
// think-style: 不收气泡、不发链接 —— 气泡留着继续流 thinking / 最终答复。
|
|
1970
|
+
if (thinkStyle) {
|
|
1971
|
+
a.briefHadTool = true;
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1864
1974
|
a.briefHadTool = true;
|
|
1865
1975
|
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
1866
1976
|
};
|
|
@@ -1873,10 +1983,41 @@ export const startMirror = (deps) => {
|
|
|
1873
1983
|
if (!turnId || a.briefConcluded || !body.trim())
|
|
1874
1984
|
return;
|
|
1875
1985
|
a.briefConcluded = true;
|
|
1876
|
-
|
|
1986
|
+
// think-style: thinking 段以 `<think>🧙 …</think>` 收口 (tag 在 <think> 内),
|
|
1987
|
+
// 再接正文; 无 thinking 时退回普通 tag 头正文。
|
|
1988
|
+
// 软后端 (softTurnEnd) 的最终正文 final===undefined, 上游按"非 final 中途文本"
|
|
1989
|
+
// 也 pushThink 进过 briefThinking —— 收口再拿它当正文 = think 与正文各一份重复。
|
|
1990
|
+
// 收口前把 briefThinking 尾部等于 body 的那段剥掉, 保证正文只出现在 <think> 之后。
|
|
1991
|
+
const think = stripTrailingBody(a.briefThinking?.trim(), body.trim());
|
|
1992
|
+
if (thinkStyle) {
|
|
1993
|
+
if (a.briefBubble && !a.briefBubble.done) {
|
|
1994
|
+
// 气泡还活: 整段 `<think>🧙 …</think>\n\n正文` 收进这一条 (raw, tag 已在 <think> 内)。
|
|
1995
|
+
const out = think ? `${openThink(a.target, think)}</think>\n\n${body}` : withSessionTag(a.target, body);
|
|
1996
|
+
void finishBriefBubble(a, out, true);
|
|
1997
|
+
}
|
|
1998
|
+
else {
|
|
1999
|
+
// 已过窗口: think 已随 10s 防抖 standalone 陆续发出, 这里先把残余 think 冲干净,
|
|
2000
|
+
// 正文单独发一条 (带 tag), 不再重复整段 think。软后端最终正文可能落在残余
|
|
2001
|
+
// 增量尾部 —— flush 前先把等于 body 的尾段摘掉, 免得 think standalone 再重一遍。
|
|
2002
|
+
const p = a.briefThinkPending;
|
|
2003
|
+
if (p) {
|
|
2004
|
+
clearTimeout(p.timer);
|
|
2005
|
+
while (p.parts.length && p.parts[p.parts.length - 1].trim() === body.trim())
|
|
2006
|
+
p.parts.pop();
|
|
2007
|
+
if (p.parts.length)
|
|
2008
|
+
flushThinkStandalone(a);
|
|
2009
|
+
else
|
|
2010
|
+
a.briefThinkPending = undefined;
|
|
2011
|
+
}
|
|
2012
|
+
sendStandalone(a, body);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
else if (a.briefHadTool || !a.briefBubble) {
|
|
1877
2016
|
sendStandalone(a, body);
|
|
1878
|
-
|
|
2017
|
+
}
|
|
2018
|
+
else {
|
|
1879
2019
|
void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId, a.target)}`);
|
|
2020
|
+
}
|
|
1880
2021
|
// 排队中的 turn 已经建了记录但还没跑, 不能被这把扫帚扫成「已完成」。
|
|
1881
2022
|
const keep = [turnId, ...(a.briefQueue ?? []).map((q) => q.turnId)];
|
|
1882
2023
|
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
|
|
@@ -1891,16 +2032,29 @@ export const startMirror = (deps) => {
|
|
|
1891
2032
|
// 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
|
|
1892
2033
|
// 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
|
|
1893
2034
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
1894
|
-
|
|
2035
|
+
if (thinkStyle) {
|
|
2036
|
+
// think-style: 不发链接。有累积 thinking 就收进气泡 (tag 在 <think> 内), 否则空收。
|
|
2037
|
+
const think = a.briefThinking?.trim();
|
|
2038
|
+
void finishBriefBubble(a, think ? `${openThink(a.target, think)}</think>` : withSessionTag(a.target, " "), true);
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
2042
|
+
}
|
|
1895
2043
|
}
|
|
1896
2044
|
recordTurnClose(a.briefTurnId);
|
|
1897
2045
|
log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
|
|
2046
|
+
// 残余 think 增量 (窗口外攒着还没到 10s) 收口时冲干净, 免得漏掉本轮最后一段思考。
|
|
2047
|
+
if (a.briefThinkPending) {
|
|
2048
|
+
clearTimeout(a.briefThinkPending.timer);
|
|
2049
|
+
flushThinkStandalone(a);
|
|
2050
|
+
}
|
|
1898
2051
|
a.briefTurnId = undefined;
|
|
1899
2052
|
a.briefBubble = undefined;
|
|
1900
2053
|
a.briefHadTool = false;
|
|
1901
2054
|
a.briefIsSlash = false;
|
|
1902
2055
|
a.briefConcluded = false;
|
|
1903
2056
|
a.briefLastText = undefined;
|
|
2057
|
+
a.briefThinking = undefined;
|
|
1904
2058
|
// 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
|
|
1905
2059
|
const next = a.briefQueue?.shift();
|
|
1906
2060
|
if (next)
|
|
@@ -1919,7 +2073,8 @@ export const startMirror = (deps) => {
|
|
|
1919
2073
|
// 一个僵尸 turn 上来当活跃 turn, 白收一遍。
|
|
1920
2074
|
for (const q of a.briefQueue ?? []) {
|
|
1921
2075
|
closed++;
|
|
1922
|
-
|
|
2076
|
+
// think-style: 不发链接, 排队气泡直接空收。
|
|
2077
|
+
void finishBubble(a, q.bubble, thinkStyle ? " " : briefDetailLink(q.turnId, a.target), thinkStyle);
|
|
1923
2078
|
recordTurnClose(q.turnId);
|
|
1924
2079
|
}
|
|
1925
2080
|
a.briefQueue = [];
|
|
@@ -1991,7 +2146,7 @@ export const startMirror = (deps) => {
|
|
|
1991
2146
|
const now = Date.now();
|
|
1992
2147
|
if (item.kind === "tool_use") {
|
|
1993
2148
|
a.briefHadTool = true; // 有工具 → 气泡收口写详情链接, 结论另发 standalone
|
|
1994
|
-
earlyLinkBubble(a); // 立刻推详情链接, 不等 turn 收口
|
|
2149
|
+
earlyLinkBubble(a); // 立刻推详情链接, 不等 turn 收口 (think-style 下为 no-op)
|
|
1995
2150
|
for (const c of item.calls) {
|
|
1996
2151
|
// 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
|
|
1997
2152
|
if (c.toolUseId) {
|
|
@@ -2011,16 +2166,40 @@ export const startMirror = (deps) => {
|
|
|
2011
2166
|
ts: now,
|
|
2012
2167
|
});
|
|
2013
2168
|
}
|
|
2169
|
+
// think-style: 工具调用也算中间过程, 以 `🔧 Name compact` 摘要进 think。
|
|
2170
|
+
if (thinkStyle) {
|
|
2171
|
+
const lines = item.calls
|
|
2172
|
+
.map((c) => `🔧 ${c.name} ${renderToolInputCompact(c.input, cfg.wrc.mirror.toolUseInlineMaxChars)}`.trim())
|
|
2173
|
+
.join("\n");
|
|
2174
|
+
pushThink(a, lines);
|
|
2175
|
+
}
|
|
2014
2176
|
return;
|
|
2015
2177
|
}
|
|
2016
2178
|
if (item.kind === "tool_result") {
|
|
2017
2179
|
earlyLinkBubble(a);
|
|
2018
2180
|
recordTurnItem(turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2181
|
+
// think-style: 工具结果 (中间输出) 也进 think, 单行截断免刷屏。
|
|
2182
|
+
if (thinkStyle)
|
|
2183
|
+
pushThink(a, `↳ ${oneLineSummary(item.full, cfg.wrc.mirror.toolUseInlineMaxChars)}`);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (item.kind === "thinking") {
|
|
2187
|
+
recordTurnItem(turnId, { t: "text", body: item.body, ts: now });
|
|
2188
|
+
if (thinkStyle)
|
|
2189
|
+
pushThink(a, item.body); // reasoning 进 think 流
|
|
2190
|
+
else
|
|
2191
|
+
a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${item.body}` : item.body;
|
|
2019
2192
|
return;
|
|
2020
2193
|
}
|
|
2021
2194
|
if (item.kind === "text") {
|
|
2022
2195
|
recordTurnItem(turnId, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2023
2196
|
a.briefLastText = item.body; // 软收口时拿它当结论 (那条路径上没有 final 标记)
|
|
2197
|
+
// think-style: 非 final 文本 (中途叙述) 也进 think 流, 与 reasoning / 工具一视同仁 ——
|
|
2198
|
+
// 只有 final 文本结束思考、成为正文。
|
|
2199
|
+
if (thinkStyle && item.final !== true) {
|
|
2200
|
+
pushThink(a, item.body);
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2024
2203
|
// final===false 才是"确知的中途输出"; undefined 是后端说不清, 不能据此提前收气泡。
|
|
2025
2204
|
if (item.final === false)
|
|
2026
2205
|
earlyLinkBubble(a);
|
|
@@ -2116,73 +2295,39 @@ export const startMirror = (deps) => {
|
|
|
2116
2295
|
clearTimeout(a.keepaliveQuiet);
|
|
2117
2296
|
a.keepaliveQuiet = undefined;
|
|
2118
2297
|
};
|
|
2119
|
-
// A
|
|
2120
|
-
// (
|
|
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
|
-
};
|
|
2298
|
+
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
2299
|
+
// matter (model may add extra commentary beyond "pong", that's fine).
|
|
2142
2300
|
const onItem = (a, item) => {
|
|
2143
2301
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
2144
2302
|
if (a.muteUntilInject)
|
|
2145
2303
|
return;
|
|
2146
2304
|
// Keepalive ping turns are cache-warmers: swallow every item from the WeCom
|
|
2147
2305
|
// paths so the ping/pong never reaches chat. But record the REAL exchange
|
|
2148
|
-
// into its chat-detail turn — the actual assistant reply
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
// synthetic summary. Closed on the terminal signal (hard or soft turn_end).
|
|
2306
|
+
// into its chat-detail turn — the actual assistant reply, the tool calls if
|
|
2307
|
+
// any, and the usage (proof it was a cheap cache-read) — so the detail page
|
|
2308
|
+
// shows the genuine heartbeat, not a synthetic summary.
|
|
2152
2309
|
if (a.keepaliveQuiet) {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
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
|
-
}
|
|
2310
|
+
const id = a.keepaliveTurnId;
|
|
2311
|
+
if (id) {
|
|
2312
|
+
const now = Date.now();
|
|
2313
|
+
if (item.kind === "text") {
|
|
2314
|
+
recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2178
2315
|
}
|
|
2179
|
-
if (item.kind === "
|
|
2180
|
-
|
|
2181
|
-
a.keepalivePongBuf = undefined;
|
|
2316
|
+
else if (item.kind === "tool_result") {
|
|
2317
|
+
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2182
2318
|
}
|
|
2183
|
-
|
|
2319
|
+
else if (item.kind === "turn_usage") {
|
|
2320
|
+
recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2321
|
+
}
|
|
2322
|
+
else if (item.kind === "turn_end") {
|
|
2323
|
+
recordTurnClose(id);
|
|
2324
|
+
a.keepaliveTurnId = undefined;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
if (item.kind === "turn_end") {
|
|
2328
|
+
endKeepaliveQuiet(a);
|
|
2184
2329
|
}
|
|
2185
|
-
|
|
2330
|
+
return;
|
|
2186
2331
|
}
|
|
2187
2332
|
// 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
|
|
2188
2333
|
// 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
|
|
@@ -2501,6 +2646,7 @@ export const startMirror = (deps) => {
|
|
|
2501
2646
|
log: log.child({ sub: "tail", sessionId }),
|
|
2502
2647
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
2503
2648
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
2649
|
+
thinkStyle: cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief,
|
|
2504
2650
|
includeToolResults: cfg.wrc.mirror.includeToolResults,
|
|
2505
2651
|
toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
|
|
2506
2652
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
@@ -2882,6 +3028,7 @@ export const startMirror = (deps) => {
|
|
|
2882
3028
|
log: log.child({ sub: "tail", sessionId: newSessionId }),
|
|
2883
3029
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
2884
3030
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
3031
|
+
thinkStyle: cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief,
|
|
2885
3032
|
includeToolResults: cfg.wrc.mirror.includeToolResults,
|
|
2886
3033
|
toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
|
|
2887
3034
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
@@ -3420,10 +3567,95 @@ export const startMirror = (deps) => {
|
|
|
3420
3567
|
// untagged default plus every `#tag`. Live attachments are the truth; the
|
|
3421
3568
|
// persisted store fills in cold bindings so a peer nobody has talked to since
|
|
3422
3569
|
// the last reload is still discoverable (and revivable) rather than invisible.
|
|
3570
|
+
const allTargets = () => {
|
|
3571
|
+
const keys = new Set([...byTarget.keys(), ...Object.keys(deps.store.all())]);
|
|
3572
|
+
return Array.from(keys).sort();
|
|
3573
|
+
};
|
|
3423
3574
|
const chatTargets = (target) => {
|
|
3424
3575
|
const base = basePrincipalOf(target);
|
|
3425
|
-
|
|
3426
|
-
|
|
3576
|
+
return allTargets().filter((k) => basePrincipalOf(k) === base);
|
|
3577
|
+
};
|
|
3578
|
+
// 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
|
|
3579
|
+
// 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
|
|
3580
|
+
const resolveChatRef = (ref) => {
|
|
3581
|
+
const base = chatBaseOf(cfg, ref);
|
|
3582
|
+
if (base)
|
|
3583
|
+
return { ok: true, base };
|
|
3584
|
+
const known = listChatNames(cfg).map((c) => c.name);
|
|
3585
|
+
return {
|
|
3586
|
+
ok: false,
|
|
3587
|
+
reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
|
|
3588
|
+
candidates: known,
|
|
3589
|
+
};
|
|
3590
|
+
};
|
|
3591
|
+
// peer 寻址。地址是两级的(见 chat-name.ts):
|
|
3592
|
+
// "" 本 chat 的 default —— 自身语义不变;
|
|
3593
|
+
// `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
|
|
3594
|
+
// 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
|
|
3595
|
+
// `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
|
|
3596
|
+
// `daily#` daily 的 default 会话;
|
|
3597
|
+
// `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
|
|
3598
|
+
// 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
|
|
3599
|
+
// 的全称地址"——后者不需要动别人的会话。
|
|
3600
|
+
const resolvePeerTag = (self, ref) => {
|
|
3601
|
+
const { chat, tag } = parsePeerRef(ref ?? "");
|
|
3602
|
+
const t = tag.trim();
|
|
3603
|
+
if (chat) {
|
|
3604
|
+
const c = resolveChatRef(chat);
|
|
3605
|
+
if (!c.ok)
|
|
3606
|
+
return c;
|
|
3607
|
+
const target = keyOf(c.base, t);
|
|
3608
|
+
if (allTargets().includes(target)) {
|
|
3609
|
+
return { ok: true, target, foreign: c.base !== basePrincipalOf(self) };
|
|
3610
|
+
}
|
|
3611
|
+
return {
|
|
3612
|
+
ok: false,
|
|
3613
|
+
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
3614
|
+
candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base),
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
const local = keyOf(basePrincipalOf(self), t);
|
|
3618
|
+
if (!t)
|
|
3619
|
+
return { ok: true, target: local, foreign: false };
|
|
3620
|
+
const all = allTargets();
|
|
3621
|
+
if (all.includes(local))
|
|
3622
|
+
return { ok: true, target: local, foreign: false };
|
|
3623
|
+
const foreignMatches = all.filter((k) => tagOfKey(k) === t && basePrincipalOf(k) !== basePrincipalOf(self));
|
|
3624
|
+
if (foreignMatches.length === 1)
|
|
3625
|
+
return { ok: true, target: foreignMatches[0], foreign: true };
|
|
3626
|
+
if (foreignMatches.length === 0) {
|
|
3627
|
+
// 裸 token 正好是个 chat 名字 —— 用户/agent 想说的是"那个群",不是"那个 tag"。
|
|
3628
|
+
// 直接给出它的会话地址,比让人再查一次 list_chats 快。
|
|
3629
|
+
const asChat = chatBaseOf(cfg, t);
|
|
3630
|
+
if (asChat) {
|
|
3631
|
+
return {
|
|
3632
|
+
ok: false,
|
|
3633
|
+
reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
|
|
3634
|
+
candidates: allTargets().filter((k) => basePrincipalOf(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
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)` };
|
|
3638
|
+
}
|
|
3639
|
+
return {
|
|
3640
|
+
ok: false,
|
|
3641
|
+
reason: `tag '#${t}' exists in ${foreignMatches.length} chats — address it in full as 'chatName#${t}' (list_chats shows the names)`,
|
|
3642
|
+
candidates: foreignMatches.map((k) => peerAddress(cfg, self, k)),
|
|
3643
|
+
};
|
|
3644
|
+
};
|
|
3645
|
+
/** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
|
|
3646
|
+
* 它暂时只能靠全局唯一 tag 被找到的原因。 */
|
|
3647
|
+
const chatRoster = (self) => {
|
|
3648
|
+
const bases = new Set([
|
|
3649
|
+
...listChatNames(cfg).map((c) => c.base),
|
|
3650
|
+
...allTargets().map(basePrincipalOf),
|
|
3651
|
+
basePrincipalOf(self),
|
|
3652
|
+
]);
|
|
3653
|
+
return [...bases].filter(Boolean).sort().map((base) => ({
|
|
3654
|
+
base,
|
|
3655
|
+
name: chatNameOf(cfg, base),
|
|
3656
|
+
self: base === basePrincipalOf(self),
|
|
3657
|
+
targets: allTargets().filter((k) => basePrincipalOf(k) === base),
|
|
3658
|
+
}));
|
|
3427
3659
|
};
|
|
3428
3660
|
const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
|
|
3429
3661
|
const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
|
|
@@ -3436,38 +3668,52 @@ export const startMirror = (deps) => {
|
|
|
3436
3668
|
const p = jsonlOf(target);
|
|
3437
3669
|
return p ? lastAssistantText(p) : "";
|
|
3438
3670
|
};
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3671
|
+
// 一个 target 的完整画像。本 chat 的兄弟和外 chat 的 peer 走同一条,只有
|
|
3672
|
+
// `self` / `address` 因观察者而异 —— 地址本来就是相对调用方说的话。
|
|
3673
|
+
const peerInfoOf = async (t, self) => {
|
|
3674
|
+
const a = byTarget.get(t);
|
|
3675
|
+
const rec = deps.store.get(t);
|
|
3676
|
+
const jsonlPath = jsonlOf(t);
|
|
3677
|
+
const pane = paneOf(t);
|
|
3678
|
+
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
3679
|
+
const tag = tagOfTarget(t);
|
|
3680
|
+
let lastActivity = 0;
|
|
3681
|
+
try {
|
|
3682
|
+
if (jsonlPath)
|
|
3683
|
+
lastActivity = statSync(jsonlPath).mtimeMs;
|
|
3684
|
+
}
|
|
3685
|
+
catch { /* not written yet */ }
|
|
3686
|
+
return {
|
|
3687
|
+
target: t,
|
|
3688
|
+
tag,
|
|
3689
|
+
chat: chatNameOf(cfg, t),
|
|
3690
|
+
address: peerAddress(cfg, self, t),
|
|
3691
|
+
label: tag ? labelFor(tag) : "🧙",
|
|
3692
|
+
sessionId: a?.sessionId || rec?.sessionId || "",
|
|
3693
|
+
jsonlPath,
|
|
3694
|
+
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
3695
|
+
cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
|
|
3696
|
+
tmuxPane: pane,
|
|
3697
|
+
attached: !!a,
|
|
3698
|
+
paneAlive,
|
|
3699
|
+
busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
|
|
3700
|
+
lastActivity,
|
|
3701
|
+
summary: jsonlPath ? summarizeTail(jsonlPath) : "(未绑定会话)",
|
|
3702
|
+
self: t === self,
|
|
3703
|
+
};
|
|
3704
|
+
};
|
|
3705
|
+
const byRecent = (x, y) => y.lastActivity - x.lastActivity;
|
|
3706
|
+
const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
|
|
3707
|
+
// list_peers 的跨 chat 补充:其他 chat 里**当前调用方叫得动**的 session。两条
|
|
3708
|
+
// 入选路径 —— tag 全局唯一(裸 tag 就能命中),或者它所在的 chat 有名字(全称
|
|
3709
|
+
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
3710
|
+
// 只要给群起个名,不必回去改别人的 tag。
|
|
3711
|
+
const foreignPeers = async (self) => {
|
|
3712
|
+
const selfBase = basePrincipalOf(self);
|
|
3713
|
+
const foreign = allTargets().filter((k) => tagOfKey(k) && basePrincipalOf(k) !== selfBase);
|
|
3714
|
+
const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
|
|
3715
|
+
const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
|
|
3716
|
+
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
3471
3717
|
};
|
|
3472
3718
|
// Capture a few extra rows then compact away the TUI's blank padding, so
|
|
3473
3719
|
// `rows` counts lines the user actually cares about.
|
|
@@ -3533,7 +3779,6 @@ export const startMirror = (deps) => {
|
|
|
3533
3779
|
// buys a deterministic reply. keepaliveStamps matches every form, so none
|
|
3534
3780
|
// reads as real activity. (round is incremented below, after the inject lands.)
|
|
3535
3781
|
const text = stalled ? kc.resumePing : kc.ping;
|
|
3536
|
-
a.keepalivePongBuf = ""; // fresh reply accumulator for the swallow/release gate
|
|
3537
3782
|
// Suppress the pane→WeCom echo of the ping user line, then swallow the whole
|
|
3538
3783
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
3539
3784
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
@@ -3678,7 +3923,11 @@ export const startMirror = (deps) => {
|
|
|
3678
3923
|
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
3679
3924
|
return {
|
|
3680
3925
|
attach,
|
|
3926
|
+
chatTargets,
|
|
3681
3927
|
peers,
|
|
3928
|
+
foreignPeers,
|
|
3929
|
+
resolvePeerTag,
|
|
3930
|
+
chatRoster,
|
|
3682
3931
|
peekPane,
|
|
3683
3932
|
peekTurns,
|
|
3684
3933
|
isBusy,
|