wezard 1.3.3 → 1.3.7
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/CLAUDE.md +3 -2
- package/README.md +30 -23
- package/cli/wezard.sh +101 -4
- package/commands/update.md +6 -0
- package/commands/wrc.md +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +35 -3
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/inbound.js +14 -8
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +25 -12
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/last-response.js +33 -5
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +395 -70
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +68 -0
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/subagent-tail.js +295 -0
- package/dist/daemon/subagent-tail.js.map +1 -0
- package/dist/daemon/tips.js +1 -1
- package/dist/daemon/tips.js.map +1 -1
- package/dist/mcp/server.js +23 -32
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config.js +17 -2
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +23 -3
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/hooks/pre-tool-use.sh +11 -1
- package/launchd/com.wezard.daemon.plist.template +12 -0
- package/package.json +2 -1
|
@@ -20,12 +20,13 @@ import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "
|
|
|
20
20
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer } from "../shared/modal-pane.js";
|
|
21
21
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
|
|
22
22
|
import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
23
|
+
import { startSubagentWatch } from "./subagent-tail.js";
|
|
23
24
|
import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
24
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader } from "../shared/session-label.js";
|
|
25
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } from "../shared/session-label.js";
|
|
25
26
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
26
27
|
import { randomTip } from "./tips.js";
|
|
27
28
|
import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
28
|
-
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
29
|
+
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, limitResetAt, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
29
30
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
30
31
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
31
32
|
const NODE_BIN_DIR = dirname(process.execPath);
|
|
@@ -465,11 +466,17 @@ const renderLine = (raw, deps) => {
|
|
|
465
466
|
const goal = goalStartOf(c);
|
|
466
467
|
if (goal)
|
|
467
468
|
return [goal];
|
|
468
|
-
if (!deps.includeUser)
|
|
469
|
-
return [];
|
|
470
469
|
const text = cleanUserText(c);
|
|
471
470
|
if (!text)
|
|
472
471
|
return []; // pure slash-command meta / stdout — drop
|
|
472
|
+
// Keepalive ping user line: judged by CONTENT, before every other gate
|
|
473
|
+
// (includeUser / isOwnInject's 60s TTL) — the ping never echoes, and
|
|
474
|
+
// the reply turn is swallowed by content in onItem. Timer windows can't
|
|
475
|
+
// cover reloads or replays; the user message itself always can.
|
|
476
|
+
if (deps.isKeepalivePing?.(text))
|
|
477
|
+
return [{ kind: "keepalive_start", body: text }];
|
|
478
|
+
if (!deps.includeUser)
|
|
479
|
+
return [];
|
|
473
480
|
if (deps.isOwnInject(text))
|
|
474
481
|
return []; // dedupe WeCom→CLI echo
|
|
475
482
|
const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
|
|
@@ -770,6 +777,7 @@ export const startMirrorTail = (deps) => {
|
|
|
770
777
|
clearInterval(poll);
|
|
771
778
|
},
|
|
772
779
|
drain,
|
|
780
|
+
livePath: resolveLive,
|
|
773
781
|
};
|
|
774
782
|
};
|
|
775
783
|
// Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
|
|
@@ -1414,6 +1422,19 @@ export const startMirror = (deps) => {
|
|
|
1414
1422
|
recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
|
|
1415
1423
|
return true;
|
|
1416
1424
|
};
|
|
1425
|
+
// Keepalive ping detector for the tail's user-line judgment — the SAME
|
|
1426
|
+
// normalized-prefix signature set keepaliveTick feeds keepaliveStamps
|
|
1427
|
+
// (every configured form + the bare "ping" legacy streak form), so
|
|
1428
|
+
// renderLine, the tick and the transcript parser all agree on what counts
|
|
1429
|
+
// as a ping. Content is the source of truth for the swallow: timers and
|
|
1430
|
+
// echo-TTLs lose it across reloads/replays.
|
|
1431
|
+
const keepalivePingSigs = [cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing]
|
|
1432
|
+
.map((p) => normAssistant(p).slice(0, 40))
|
|
1433
|
+
.filter((s) => s.length > 0);
|
|
1434
|
+
const isKeepalivePing = (text) => {
|
|
1435
|
+
const n = normAssistant(text);
|
|
1436
|
+
return n.toLowerCase() === "ping" || keepalivePingSigs.some((sig) => n.includes(sig));
|
|
1437
|
+
};
|
|
1417
1438
|
// ── Typewriter stream lifecycle ────────────────────────────────────
|
|
1418
1439
|
// WeCom spec: server polls us for stream refreshes for up to 6 min from the
|
|
1419
1440
|
// original inbound. SDK queues replyStream calls per req_id, sends serially
|
|
@@ -1480,6 +1501,9 @@ export const startMirror = (deps) => {
|
|
|
1480
1501
|
if (s.closed)
|
|
1481
1502
|
return;
|
|
1482
1503
|
s.closed = true;
|
|
1504
|
+
// 非 brief 路径的父 turn 收口 — subagent turns 一并关 (brief 路径在
|
|
1505
|
+
// closeBriefTurn 关, 双关幂等)。
|
|
1506
|
+
closeSubagentTurns(a);
|
|
1483
1507
|
if (s.flushTimer) {
|
|
1484
1508
|
clearTimeout(s.flushTimer);
|
|
1485
1509
|
s.flushTimer = undefined;
|
|
@@ -1558,7 +1582,14 @@ export const startMirror = (deps) => {
|
|
|
1558
1582
|
}
|
|
1559
1583
|
return withSessionTag(a.target, content, seq);
|
|
1560
1584
|
};
|
|
1585
|
+
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
|
|
1586
|
+
// 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
|
|
1587
|
+
// 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
|
|
1588
|
+
// 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
|
|
1589
|
+
const hasVisibleBody = (content) => parseTagHeader(content).body.length > 0;
|
|
1561
1590
|
const sendStandalone = (a, content) => {
|
|
1591
|
+
if (!hasVisibleBody(content))
|
|
1592
|
+
return;
|
|
1562
1593
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1563
1594
|
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1564
1595
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
@@ -1577,6 +1608,8 @@ export const startMirror = (deps) => {
|
|
|
1577
1608
|
};
|
|
1578
1609
|
// Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
|
|
1579
1610
|
const sendRaw = (a, content) => {
|
|
1611
|
+
if (!hasVisibleBody(content))
|
|
1612
|
+
return;
|
|
1580
1613
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1581
1614
|
a.standalonePending = a.standalonePending
|
|
1582
1615
|
.then(async () => {
|
|
@@ -1614,16 +1647,20 @@ export const startMirror = (deps) => {
|
|
|
1614
1647
|
};
|
|
1615
1648
|
// Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
|
|
1616
1649
|
// 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
|
|
1650
|
+
// parts 在 enqueue 时已过 hasVisibleBody, flush 端再过滤一遍是纵深防御 ——
|
|
1651
|
+
// 防的是绕过 enqueue 直写 buf 的未来代码路径。
|
|
1617
1652
|
const flushStandalone = (a) => {
|
|
1618
1653
|
const buf = a.standaloneBuf;
|
|
1619
1654
|
if (!buf)
|
|
1620
1655
|
return;
|
|
1621
|
-
const merged = buf.parts.join("\n\n");
|
|
1622
1656
|
a.standaloneBuf = undefined;
|
|
1657
|
+
const merged = buf.parts.filter((p) => hasVisibleBody(p)).join("\n\n");
|
|
1623
1658
|
if (merged)
|
|
1624
1659
|
sendStandalone(a, merged);
|
|
1625
1660
|
};
|
|
1626
1661
|
const enqueueStandalone = (a, content) => {
|
|
1662
|
+
if (!hasVisibleBody(content))
|
|
1663
|
+
return;
|
|
1627
1664
|
const ms = cfg.wrc.mirror.standaloneDebounceMs;
|
|
1628
1665
|
if (ms <= 0) {
|
|
1629
1666
|
sendStandalone(a, content);
|
|
@@ -1818,7 +1855,7 @@ export const startMirror = (deps) => {
|
|
|
1818
1855
|
// 每个 turn 挂一条气泡: ack 时就写进 `tag 详情链接 …` (finish=false, 不立刻关掉),
|
|
1819
1856
|
// 让群里从收到消息那一刻起就有详情页入口。收口时:
|
|
1820
1857
|
// • 正文到位 → 以 `链接 正文` 覆盖这条气泡。
|
|
1821
|
-
// • 始终没正文 / 过 6min 窗口 →
|
|
1858
|
+
// • 始终没正文 / 过 6min 窗口 → 以最新 CoT 进度行 (没有则纯 `链接`) 收口。
|
|
1822
1859
|
// • 气泡已收口或无气泡 turn (CLI 侧发起) → 正文另发一条 standalone。
|
|
1823
1860
|
// 其它所有 item 只写入 turn detail store。
|
|
1824
1861
|
// 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
|
|
@@ -1857,6 +1894,7 @@ export const startMirror = (deps) => {
|
|
|
1857
1894
|
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
1858
1895
|
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
1859
1896
|
return;
|
|
1897
|
+
a.cotLastSent = text;
|
|
1860
1898
|
try {
|
|
1861
1899
|
await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
|
|
1862
1900
|
}
|
|
@@ -1951,8 +1989,11 @@ export const startMirror = (deps) => {
|
|
|
1951
1989
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
1952
1990
|
const q = { turnId, bubble, isSlash };
|
|
1953
1991
|
bubble.hardTimer = setTimeout(() => {
|
|
1954
|
-
// WeCom ~6min stream 窗口快到, 必须 finish=true
|
|
1955
|
-
|
|
1992
|
+
// WeCom ~6min stream 窗口快到, 必须 finish=true 收口。没正文时不能拿光链接
|
|
1993
|
+
// 覆盖 —— finish 是整条替换, 会把屏幕上的 CoT 进度行抹成光秃秃的链接; 改用
|
|
1994
|
+
// 最新进度行定格 (a 上的 CoT 只在气泡仍是本轮活跃气泡时才可信, 换轮后归新轮)。
|
|
1995
|
+
const cot = a.briefBubble === bubble && !a.briefConcluded ? a.cotText ?? a.cotLastSent : undefined;
|
|
1996
|
+
void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${cot ? ` \`${cot}\`` : ""}`, true);
|
|
1956
1997
|
}, HARD_TIMEOUT_MS);
|
|
1957
1998
|
// 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
|
|
1958
1999
|
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
@@ -1992,7 +2033,9 @@ export const startMirror = (deps) => {
|
|
|
1992
2033
|
a.briefConcluded = false;
|
|
1993
2034
|
a.briefLastText = undefined;
|
|
1994
2035
|
clearCot(a);
|
|
1995
|
-
|
|
2036
|
+
// 链接暂存, 不再单独发 — 让首条 standalone body 取走拼到前缀, 一条消息解决。
|
|
2037
|
+
// turn 始终会通过 concludeBriefTurn / closeBriefTurn 收口, header 不会泄漏。
|
|
2038
|
+
a.pendingBriefHeader = briefDetailLink(turnId, a.target);
|
|
1996
2039
|
log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
|
|
1997
2040
|
};
|
|
1998
2041
|
// 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
|
|
@@ -2003,19 +2046,28 @@ export const startMirror = (deps) => {
|
|
|
2003
2046
|
a.briefHadTool = true;
|
|
2004
2047
|
};
|
|
2005
2048
|
// 本轮结论落地, 只生效一次:
|
|
2006
|
-
// • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口;
|
|
2007
|
-
//
|
|
2008
|
-
//
|
|
2049
|
+
// • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口; ensureBriefTurn
|
|
2050
|
+
// 暂存的 header 跟着 bubble 收口一并丢 (气泡内已自带链接)。
|
|
2051
|
+
// • 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → 把暂存 header
|
|
2052
|
+
// 拼到 body 前缀一并 standalone, 一条消息含详情入口 + 正文。
|
|
2053
|
+
// • body 为空 → 一个字不发, 顺手清掉 header, 不留"只有链接"的空消息。
|
|
2009
2054
|
const concludeBriefTurn = (a, body) => {
|
|
2010
2055
|
const turnId = a.briefTurnId;
|
|
2011
|
-
if (!turnId || a.briefConcluded
|
|
2056
|
+
if (!turnId || a.briefConcluded)
|
|
2057
|
+
return;
|
|
2058
|
+
if (!body.trim()) {
|
|
2059
|
+
a.pendingBriefHeader = undefined;
|
|
2012
2060
|
return;
|
|
2061
|
+
}
|
|
2013
2062
|
a.briefConcluded = true;
|
|
2014
2063
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2015
2064
|
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2065
|
+
a.pendingBriefHeader = undefined;
|
|
2016
2066
|
}
|
|
2017
2067
|
else {
|
|
2018
|
-
|
|
2068
|
+
const header = a.pendingBriefHeader;
|
|
2069
|
+
a.pendingBriefHeader = undefined;
|
|
2070
|
+
sendStandalone(a, header ? `${header} ${body}` : body);
|
|
2019
2071
|
}
|
|
2020
2072
|
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
2021
2073
|
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
@@ -2037,8 +2089,123 @@ export const startMirror = (deps) => {
|
|
|
2037
2089
|
a.briefIsSlash = false;
|
|
2038
2090
|
a.briefConcluded = false;
|
|
2039
2091
|
a.briefLastText = undefined;
|
|
2092
|
+
a.pendingBriefHeader = undefined;
|
|
2093
|
+
closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
|
|
2040
2094
|
clearCot(a);
|
|
2041
2095
|
};
|
|
2096
|
+
// ── Subagent turns ─────────────────────────────────────────────────
|
|
2097
|
+
// Task/Agent 工具派出的子 agent 在自己的转录文件里跑 (主 jsonl 只有派发与最终
|
|
2098
|
+
// 总结)。subagent-tail 把过程流式吐到这里: 记成带 agent 标记的 turn (chat
|
|
2099
|
+
// detail 时间轴内联展示), 并在 brief 气泡里以 `🤖 label · 最新活动` 驱动 CoT
|
|
2100
|
+
// 进度行 —— 父 agent 阻塞在 Agent 调用上时, 子 agent 就是唯一的进度来源,
|
|
2101
|
+
// last-writer-wins 让"最后的消息是 agent 调用"期间气泡展示的正是 agent 内状态。
|
|
2102
|
+
// agent 类型归属: claude 的 meta.json 优先; codebuddy 没有 meta — 用父侧捕获的
|
|
2103
|
+
// Task/Agent 入参按 prompt 原文对上 (子文件首行 user 内容 == input.prompt,
|
|
2104
|
+
// codebuddy 实测逐字一致), 对不上再退到最近一次调用。
|
|
2105
|
+
// 返回值同喂两条消费方: detail 记录的 agent.type/description 与 brief 进度行的
|
|
2106
|
+
// label —— 二者必须同源, 否则"支持 codebuddy"时页面上叫 `🤖 subagent`、气泡里却
|
|
2107
|
+
// 叫解析出的类型, 自相矛盾。undefined = 无从归属 (老文件无 meta 且父侧无调用)。
|
|
2108
|
+
const resolveSubagentMeta = (a, task, meta) => {
|
|
2109
|
+
if (meta.type || meta.description)
|
|
2110
|
+
return { ...meta, label: meta.type ?? meta.description.slice(0, 24) };
|
|
2111
|
+
const calls = a.agentCalls ?? [];
|
|
2112
|
+
const hit = calls.find((c) => c.prompt && (c.prompt === task || task.startsWith(c.prompt) || c.prompt.startsWith(task)));
|
|
2113
|
+
const m = hit ?? [...calls].reverse().find((c) => Date.now() - c.at < 10 * 60_000);
|
|
2114
|
+
if (!m)
|
|
2115
|
+
return undefined;
|
|
2116
|
+
const { type, description } = m;
|
|
2117
|
+
return { type, description, label: type ?? description?.slice(0, 24) ?? "subagent" };
|
|
2118
|
+
};
|
|
2119
|
+
const closeSubagentRun = (a, agentId) => {
|
|
2120
|
+
const run = a.subagents?.get(agentId);
|
|
2121
|
+
if (!run || run.closed)
|
|
2122
|
+
return;
|
|
2123
|
+
run.closed = true;
|
|
2124
|
+
recordTurnClose(run.turnId);
|
|
2125
|
+
};
|
|
2126
|
+
// 关掉该 attachment 所有开着的 subagent turn。父 turn 收口 (closeBriefTurn /
|
|
2127
|
+
// finalizeStream / detach / migrate) 时调用 — subagent 必然先于父 turn 结束,
|
|
2128
|
+
// 父都收了, 子的开着只会让页面永远「运行中」。
|
|
2129
|
+
const closeSubagentTurns = (a) => {
|
|
2130
|
+
for (const id of a.subagents?.keys() ?? [])
|
|
2131
|
+
closeSubagentRun(a, id);
|
|
2132
|
+
};
|
|
2133
|
+
const handleSubagentItem = (a, agentId, item) => {
|
|
2134
|
+
if (!a.subagents)
|
|
2135
|
+
a.subagents = new Map();
|
|
2136
|
+
let run = a.subagents.get(agentId);
|
|
2137
|
+
const now = Date.now();
|
|
2138
|
+
// 首个 item 懒建 turn — task 行建在开头 (userQuery=任务原文), EOF 起步的存量
|
|
2139
|
+
// agent 任何 item 都能建 (userQuery 缺省), 不丢过程。
|
|
2140
|
+
if (!run && item.kind !== "end") {
|
|
2141
|
+
const task = item.kind === "task" ? item.body : undefined;
|
|
2142
|
+
const meta = item.kind === "task" ? item.meta : {};
|
|
2143
|
+
const resolved = resolveSubagentMeta(a, task ?? "", meta);
|
|
2144
|
+
const turnId = newTurnId();
|
|
2145
|
+
run = { turnId, label: resolved?.label ?? "subagent", closed: false };
|
|
2146
|
+
a.subagents.set(agentId, run);
|
|
2147
|
+
recordTurnStart({
|
|
2148
|
+
id: turnId,
|
|
2149
|
+
target: a.target,
|
|
2150
|
+
sessionId: a.sessionId,
|
|
2151
|
+
cwd: a.runningCwd || undefined,
|
|
2152
|
+
userQuery: task,
|
|
2153
|
+
agent: { id: agentId, type: resolved?.type, description: resolved?.description },
|
|
2154
|
+
});
|
|
2155
|
+
log.info({ agentId, turnId, label: run.label }, "subagent: turn started");
|
|
2156
|
+
if (item.kind === "task")
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
if (!run || run.closed)
|
|
2160
|
+
return;
|
|
2161
|
+
switch (item.kind) {
|
|
2162
|
+
case "task":
|
|
2163
|
+
return; // 已在建 turn 时消费
|
|
2164
|
+
case "text":
|
|
2165
|
+
recordTurnItem(run.turnId, { t: "text", body: item.body, ts: now, final: item.final });
|
|
2166
|
+
break;
|
|
2167
|
+
case "tool_use":
|
|
2168
|
+
for (const c of item.calls) {
|
|
2169
|
+
recordTurnItem(run.turnId, { t: "tool_use", toolUseId: c.toolUseId, toolName: c.name, toolInput: c.input, ts: now });
|
|
2170
|
+
}
|
|
2171
|
+
break;
|
|
2172
|
+
case "tool_result":
|
|
2173
|
+
recordTurnItem(run.turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2174
|
+
break;
|
|
2175
|
+
case "usage":
|
|
2176
|
+
recordTurnUsage(run.turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2177
|
+
break;
|
|
2178
|
+
case "end":
|
|
2179
|
+
closeSubagentRun(a, agentId);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
// brief 气泡的实时进度: 静默窗 (keepalive 吞没 / 新 pane 未注入) 不推;
|
|
2183
|
+
// updateBriefProgress 自身会检查气泡是否仍开。final text 之后的 end 会很快
|
|
2184
|
+
// 收口气泡, 进度行不会残留。
|
|
2185
|
+
if (a.muteUntilInject || a.keepaliveQuiet || a.keepaliveByContent)
|
|
2186
|
+
return;
|
|
2187
|
+
const activity = item.kind === "tool_use"
|
|
2188
|
+
? cotToolLabel(item.calls)
|
|
2189
|
+
: item.kind === "thinking" || item.kind === "text"
|
|
2190
|
+
? item.body
|
|
2191
|
+
: "";
|
|
2192
|
+
if (activity)
|
|
2193
|
+
updateBriefProgress(a, `🤖 ${run.label} · ${activity}`);
|
|
2194
|
+
};
|
|
2195
|
+
// (重新) 挂上 subagent watch — attach 与 migrateAttachment 共用; 会话轮换后
|
|
2196
|
+
// sessionId 变了, 旧 watch 的目录定位随之作废, 必须重建。
|
|
2197
|
+
const startSubagentsFor = (a) => {
|
|
2198
|
+
a.subagentWatch?.stop();
|
|
2199
|
+
closeSubagentTurns(a);
|
|
2200
|
+
a.subagents = new Map();
|
|
2201
|
+
a.subagentWatch = startSubagentWatch({
|
|
2202
|
+
log: log.child({ sub: "subagents", sessionId: a.sessionId }),
|
|
2203
|
+
sessionId: a.sessionId,
|
|
2204
|
+
liveJsonlPath: () => a.tail.livePath(),
|
|
2205
|
+
normalizeLine: backendForPath(a.jsonlPath).normalizeTranscriptLine,
|
|
2206
|
+
onAgentItem: (agentId, item) => handleSubagentItem(a, agentId, item),
|
|
2207
|
+
});
|
|
2208
|
+
};
|
|
2042
2209
|
// 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
|
|
2043
2210
|
//
|
|
2044
2211
|
// 关键约束: **一个 tmux 调用都不许有**。它的两个调用方 (`/stop` 和 inject
|
|
@@ -2057,6 +2224,7 @@ export const startMirror = (deps) => {
|
|
|
2057
2224
|
clearTimeout(a.softEnd);
|
|
2058
2225
|
a.softEnd = undefined;
|
|
2059
2226
|
}
|
|
2227
|
+
closeSubagentTurns(a); // brief/liveStream 都不在的收口路径也把 subagent 关掉
|
|
2060
2228
|
if (a.outbound) {
|
|
2061
2229
|
if (a.outbound.kind === "deferred")
|
|
2062
2230
|
clearTimeout(a.outbound.timer);
|
|
@@ -2169,9 +2337,14 @@ export const startMirror = (deps) => {
|
|
|
2169
2337
|
// loading 气泡。非 slash 场景的 skill_output 是用户可见的中间反馈, 走 standalone。
|
|
2170
2338
|
if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
|
|
2171
2339
|
void finishBriefBubble(a, item.body);
|
|
2340
|
+
a.pendingBriefHeader = undefined;
|
|
2172
2341
|
}
|
|
2173
2342
|
else {
|
|
2174
|
-
|
|
2343
|
+
// CLI-driven turn: 把 ensureBriefTurn 暂存的详情链接拼到 body 前缀, 避免
|
|
2344
|
+
// "只发了一条 link 渲染成空文本" 的前置消息。
|
|
2345
|
+
const header = a.pendingBriefHeader;
|
|
2346
|
+
a.pendingBriefHeader = undefined;
|
|
2347
|
+
sendStandalone(a, header ? `${header} ${item.body}` : item.body);
|
|
2175
2348
|
}
|
|
2176
2349
|
return;
|
|
2177
2350
|
}
|
|
@@ -2232,13 +2405,41 @@ export const startMirror = (deps) => {
|
|
|
2232
2405
|
if (a.liveStream && !a.liveStream.closed)
|
|
2233
2406
|
void finalizeStream(a, a.liveStream);
|
|
2234
2407
|
};
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2408
|
+
// End the keepalive swallow — the ping turn is over (turn_end / genuine
|
|
2409
|
+
// user line / inject failure), so real items flow through onItem again.
|
|
2410
|
+
// Clears BOTH the timer window and the content flag.
|
|
2411
|
+
const endKeepaliveSwallow = (a) => {
|
|
2412
|
+
if (a.keepaliveQuiet) {
|
|
2413
|
+
clearTimeout(a.keepaliveQuiet);
|
|
2414
|
+
a.keepaliveQuiet = undefined;
|
|
2415
|
+
}
|
|
2416
|
+
if (a.keepaliveContentTimer) {
|
|
2417
|
+
clearTimeout(a.keepaliveContentTimer);
|
|
2418
|
+
a.keepaliveContentTimer = undefined;
|
|
2419
|
+
}
|
|
2420
|
+
a.keepaliveByContent = undefined;
|
|
2421
|
+
};
|
|
2422
|
+
// Content-based swallow entry: the tailed user line IS a configured ping.
|
|
2423
|
+
// Belt to the inject-time timer window — this one keys on the user message
|
|
2424
|
+
// itself, so it holds across daemon reloads, tail replays and replies
|
|
2425
|
+
// slower than the 60s fail-safe. Reuses the detail turn the timer flow may
|
|
2426
|
+
// have opened (fireKeepalive skips opening when this got there first).
|
|
2427
|
+
const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
|
|
2428
|
+
const beginKeepaliveByContent = (a, text) => {
|
|
2429
|
+
a.keepaliveByContent = true;
|
|
2430
|
+
if (a.keepaliveContentTimer)
|
|
2431
|
+
clearTimeout(a.keepaliveContentTimer);
|
|
2432
|
+
// Fail-safe: a ping turn whose reply never emits turn_end (crash/hang)
|
|
2433
|
+
// must not mute a later real turn forever.
|
|
2434
|
+
a.keepaliveContentTimer = setTimeout(() => {
|
|
2435
|
+
a.keepaliveContentTimer = undefined;
|
|
2436
|
+
a.keepaliveByContent = undefined;
|
|
2437
|
+
}, KEEPALIVE_CONTENT_FAILSAFE_MS);
|
|
2438
|
+
if (!a.keepaliveTurnId) {
|
|
2439
|
+
const turnId = newTurnId();
|
|
2440
|
+
a.keepaliveTurnId = turnId;
|
|
2441
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
2442
|
+
}
|
|
2242
2443
|
};
|
|
2243
2444
|
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
2244
2445
|
// matter (model may add extra commentary beyond "pong", that's fine).
|
|
@@ -2246,12 +2447,24 @@ export const startMirror = (deps) => {
|
|
|
2246
2447
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
2247
2448
|
if (a.muteUntilInject)
|
|
2248
2449
|
return;
|
|
2249
|
-
// Keepalive ping turns are cache-warmers: swallow every item from
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2254
|
-
|
|
2450
|
+
// Keepalive ping turns are cache-warmers: swallow every item from every
|
|
2451
|
+
// WeCom path so the ping/pong never reaches chat. Two triggers, either
|
|
2452
|
+
// suffices — the timer window opened at inject (keepaliveQuiet) OR the
|
|
2453
|
+
// user line itself matching a configured ping (keepaliveByContent; the
|
|
2454
|
+
// reload/replay/slow-reply-proof one). The REAL exchange is recorded
|
|
2455
|
+
// into its chat-detail turn — the actual assistant reply, the tool calls
|
|
2456
|
+
// if any, and the usage — so the detail page shows the genuine heartbeat.
|
|
2457
|
+
if (item.kind === "keepalive_start") {
|
|
2458
|
+
beginKeepaliveByContent(a, item.body);
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
// A genuine user line (not our inject, not a ping) is a hard turn
|
|
2462
|
+
// boundary — any keepalive swallow still holding never got its turn_end
|
|
2463
|
+
// (crashed reply). Release BEFORE the swallow gate so this real line (and
|
|
2464
|
+
// its reply) mirror normally instead of being eaten by the stale swallow.
|
|
2465
|
+
if (item.kind === "user_text")
|
|
2466
|
+
endKeepaliveSwallow(a);
|
|
2467
|
+
if (a.keepaliveQuiet || a.keepaliveByContent) {
|
|
2255
2468
|
const id = a.keepaliveTurnId;
|
|
2256
2469
|
if (id) {
|
|
2257
2470
|
const now = Date.now();
|
|
@@ -2270,7 +2483,7 @@ export const startMirror = (deps) => {
|
|
|
2270
2483
|
}
|
|
2271
2484
|
}
|
|
2272
2485
|
if (item.kind === "turn_end") {
|
|
2273
|
-
|
|
2486
|
+
endKeepaliveSwallow(a);
|
|
2274
2487
|
}
|
|
2275
2488
|
return;
|
|
2276
2489
|
}
|
|
@@ -2310,6 +2523,17 @@ export const startMirror = (deps) => {
|
|
|
2310
2523
|
break;
|
|
2311
2524
|
a.recentToolSigs.delete(oldest);
|
|
2312
2525
|
}
|
|
2526
|
+
// Task/Agent 派发入参留档 — subagent 文件本身不带类型信息 (codebuddy),
|
|
2527
|
+
// subagentLabel 拿它按 prompt 匹配出 agent 类型。
|
|
2528
|
+
if (c.name === "Task" || c.name === "Agent") {
|
|
2529
|
+
const inp = c.input;
|
|
2530
|
+
a.agentCalls = [...(a.agentCalls ?? []).slice(-8), {
|
|
2531
|
+
prompt: typeof inp?.prompt === "string" ? inp.prompt : "",
|
|
2532
|
+
type: typeof inp?.subagent_type === "string" && inp.subagent_type ? inp.subagent_type : undefined,
|
|
2533
|
+
description: typeof inp?.description === "string" && inp.description ? inp.description : undefined,
|
|
2534
|
+
at: Date.now(),
|
|
2535
|
+
}];
|
|
2536
|
+
}
|
|
2313
2537
|
// codebuddy 的 ExitPlanMode 完全不过 PreToolUse hook (实测: 由
|
|
2314
2538
|
// interruption-service 本地对话框裁决, HookExecutor 零调用)。mirror 从
|
|
2315
2539
|
// jsonl 看到 function_call → 发计划审批卡; 点选后 send-keys 裁决本地
|
|
@@ -2542,6 +2766,9 @@ export const startMirror = (deps) => {
|
|
|
2542
2766
|
clearTimeout(a.standaloneBuf.timer);
|
|
2543
2767
|
flushStandalone(a);
|
|
2544
2768
|
}
|
|
2769
|
+
a.subagentWatch?.stop();
|
|
2770
|
+
a.subagentWatch = undefined;
|
|
2771
|
+
closeSubagentTurns(a);
|
|
2545
2772
|
a.tail.stop();
|
|
2546
2773
|
bySessionId.delete(a.sessionId);
|
|
2547
2774
|
if (byTarget.get(a.target) === a)
|
|
@@ -2596,7 +2823,7 @@ export const startMirror = (deps) => {
|
|
|
2596
2823
|
// collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
|
|
2597
2824
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
2598
2825
|
pendingCwd: carryPending,
|
|
2599
|
-
tail: { stop: () => undefined, drain: () => undefined }, // placeholder; replaced below
|
|
2826
|
+
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
2600
2827
|
standalonePending: Promise.resolve(),
|
|
2601
2828
|
recentToolSigs: new Map(),
|
|
2602
2829
|
};
|
|
@@ -2610,6 +2837,7 @@ export const startMirror = (deps) => {
|
|
|
2610
2837
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
2611
2838
|
isOwnInject,
|
|
2612
2839
|
isOwnAssistantSend,
|
|
2840
|
+
isKeepalivePing,
|
|
2613
2841
|
onItem: (item) => onItem(a, item),
|
|
2614
2842
|
detailUrlFor,
|
|
2615
2843
|
sessionId,
|
|
@@ -2618,6 +2846,7 @@ export const startMirror = (deps) => {
|
|
|
2618
2846
|
// what lets a claude session and a codebuddy session be mirrored at once.
|
|
2619
2847
|
normalizeLine: backendForPath(jsonlPath).normalizeTranscriptLine,
|
|
2620
2848
|
});
|
|
2849
|
+
startSubagentsFor(a);
|
|
2621
2850
|
bySessionId.set(sessionId, a);
|
|
2622
2851
|
byTarget.set(target, a);
|
|
2623
2852
|
// Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
|
|
@@ -2991,6 +3220,7 @@ export const startMirror = (deps) => {
|
|
|
2991
3220
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
2992
3221
|
isOwnInject,
|
|
2993
3222
|
isOwnAssistantSend,
|
|
3223
|
+
isKeepalivePing,
|
|
2994
3224
|
onItem: (item) => onItem(a, item),
|
|
2995
3225
|
detailUrlFor,
|
|
2996
3226
|
sessionId: newSessionId,
|
|
@@ -3000,6 +3230,8 @@ export const startMirror = (deps) => {
|
|
|
3000
3230
|
// path, not by CLI) — re-derive the dialect from the destination.
|
|
3001
3231
|
normalizeLine: backendForPath(newJsonlPath).normalizeTranscriptLine,
|
|
3002
3232
|
});
|
|
3233
|
+
// 会话轮换 = 新 <sid>/subagents/ 目录; 旧 run 的 turn 一并收口。
|
|
3234
|
+
startSubagentsFor(a);
|
|
3003
3235
|
deps.store.set(a.target, {
|
|
3004
3236
|
sessionId: newSessionId,
|
|
3005
3237
|
jsonlPath: newJsonlPath,
|
|
@@ -3289,33 +3521,13 @@ export const startMirror = (deps) => {
|
|
|
3289
3521
|
// from the running cwd). Decoupling means /pwd can show truth even after
|
|
3290
3522
|
// the AI sets a new path but the user hasn't /new'd yet.
|
|
3291
3523
|
const expandedDefaultCwd = expandHome(cfg.wrc.cwd);
|
|
3292
|
-
// Long absolute paths wrap in the WeCom bubble — show only the trailing
|
|
3293
|
-
// three segments, which is enough to identify the project.
|
|
3294
|
-
const shortCwd = (p) => p.split("/").filter(Boolean).slice(-3).join("/");
|
|
3295
|
-
const renderProjectInfo = (target) => {
|
|
3296
|
-
const a = byTarget.get(target);
|
|
3297
|
-
const rec = a ? undefined : deps.store.get(target);
|
|
3298
|
-
const running = (a?.runningCwd?.trim()) || rec?.cwd?.trim() || expandedDefaultCwd;
|
|
3299
|
-
// pendingCwd is chat-scoped — the queued switch applies to every session
|
|
3300
|
-
// in this chat, so read it from the shared base slot rather than the
|
|
3301
|
-
// caller's own (now-empty) attachment record.
|
|
3302
|
-
const pending = chatCwdFallback(target).pending;
|
|
3303
|
-
const lines = [`📂 cwd: \`${shortCwd(running)}\``];
|
|
3304
|
-
if (pending && pending !== running) {
|
|
3305
|
-
lines.push(`下次切换: \`${shortCwd(pending)}\` (使用 /new 或 /clear 生效)`);
|
|
3306
|
-
}
|
|
3307
|
-
// Session-boundary footer — `/new` and `/clear` are the only two callers,
|
|
3308
|
-
// so the tip lands exactly once per fresh context, never mid-conversation.
|
|
3309
|
-
lines.push(randomTip());
|
|
3310
|
-
return lines.join("\n");
|
|
3311
|
-
};
|
|
3312
3524
|
// `header` folds the caller's ack ("created") into this same bubble — /new
|
|
3313
3525
|
// must land as exactly ONE WeCom message, not card + separate reply.
|
|
3314
|
-
//
|
|
3526
|
+
// 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
|
|
3527
|
+
// slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
|
|
3315
3528
|
const pushProjectInfo = (target, header) => {
|
|
3316
3529
|
const concise = cfg.wrc.mirror.slashAckFirstLine && !!header;
|
|
3317
|
-
const
|
|
3318
|
-
const md = header ? (info ? `${header}\n\n${info}` : header) : info;
|
|
3530
|
+
const md = header ? (concise ? header : `${header}\n\n${randomTip()}`) : randomTip();
|
|
3319
3531
|
const a = byTarget.get(target);
|
|
3320
3532
|
if (a) {
|
|
3321
3533
|
sendStandalone(a, md);
|
|
@@ -3332,7 +3544,7 @@ export const startMirror = (deps) => {
|
|
|
3332
3544
|
// (default + any `#tag` siblings) share one cwd/pendingCwd, tracked on the
|
|
3333
3545
|
// BASE principal's byTarget/store record. Tagged sessions still have their
|
|
3334
3546
|
// own `runningCwd` (the tmux pane's actual working dir at spawn time), but
|
|
3335
|
-
// cwd fallbacks and `
|
|
3547
|
+
// cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
|
|
3336
3548
|
const chatCwdFallback = (target) => {
|
|
3337
3549
|
const base = basePrincipalOf(target);
|
|
3338
3550
|
const baseA = byTarget.get(base);
|
|
@@ -3351,7 +3563,7 @@ export const startMirror = (deps) => {
|
|
|
3351
3563
|
// base.pending > caller.pending > target.running > base.running > default
|
|
3352
3564
|
// A fresh tagged session inherits the chat's current cwd; re-`/new`ing a
|
|
3353
3565
|
// live tagged session keeps its pane cwd unless the base session queued a
|
|
3354
|
-
//
|
|
3566
|
+
// cwd switch. This keeps siblings aligned by default without forcibly clobbering
|
|
3355
3567
|
// an already-spawned tagged pane on every base-cwd change.
|
|
3356
3568
|
const chat = chatCwdFallback(target);
|
|
3357
3569
|
const rec = !prev ? deps.store.get(target) : undefined;
|
|
@@ -3449,9 +3661,9 @@ export const startMirror = (deps) => {
|
|
|
3449
3661
|
return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
|
|
3450
3662
|
};
|
|
3451
3663
|
const getCwd = (target) => {
|
|
3452
|
-
// pendingCwd is chat-scoped — a `
|
|
3453
|
-
// switch for the whole chat. runningCwd stays per-session (each
|
|
3454
|
-
// its own spawn dir).
|
|
3664
|
+
// pendingCwd is chat-scoped — a `set_workspace` from any sibling session
|
|
3665
|
+
// queues the switch for the whole chat. runningCwd stays per-session (each
|
|
3666
|
+
// pane has its own spawn dir).
|
|
3455
3667
|
const chat = chatCwdFallback(target);
|
|
3456
3668
|
const pending = chat.pending;
|
|
3457
3669
|
const a = byTarget.get(target);
|
|
@@ -3463,9 +3675,9 @@ export const startMirror = (deps) => {
|
|
|
3463
3675
|
return { runningCwd: chat.running || expandedDefaultCwd, pendingCwd: pending, defaultCwd: expandedDefaultCwd };
|
|
3464
3676
|
};
|
|
3465
3677
|
// Write `pendingCwd` to the BASE principal so the switch applies chat-wide —
|
|
3466
|
-
// the next /new in any tagged/untagged session picks it up. `
|
|
3467
|
-
// tagged session still writes to the shared slot, not the tagged
|
|
3468
|
-
// own record, matching "sessions share the chat's cwd".
|
|
3678
|
+
// the next /new in any tagged/untagged session picks it up. `set_workspace`
|
|
3679
|
+
// from a tagged session still writes to the shared slot, not the tagged
|
|
3680
|
+
// session's own record, matching "sessions share the chat's cwd".
|
|
3469
3681
|
const setPendingCwd = (target, cwd) => {
|
|
3470
3682
|
const trimmed = (cwd ?? "").trim();
|
|
3471
3683
|
if (!trimmed)
|
|
@@ -3627,6 +3839,34 @@ export const startMirror = (deps) => {
|
|
|
3627
3839
|
const p = jsonlOf(target);
|
|
3628
3840
|
return p ? lastAssistantText(p) : "";
|
|
3629
3841
|
};
|
|
3842
|
+
// ── 未收口气泡的引用判定 ────────────────────────────────────────────
|
|
3843
|
+
// tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
|
|
3844
|
+
// 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
|
|
3845
|
+
// 不是把这些中间态贴回 prompt —— 判定命中 ⇒ 调用方只保留 tag, 正文丢弃。
|
|
3846
|
+
// URL 必须先于 canon 剥掉: canonForCompare 只剩字母数字, host/path 的字符会
|
|
3847
|
+
// 永远卡住比对。两侧同剥, 才在同一层级上比。
|
|
3848
|
+
const OPEN_QUOTE_URL_RE = /https?:\/\/\S+/g;
|
|
3849
|
+
const quoteCanon = (s) => s.replace(OPEN_QUOTE_URL_RE, " ").replace(/[^\p{L}\p{N}]/gu, "");
|
|
3850
|
+
const openBubbleBodies = (a) => {
|
|
3851
|
+
if (a.briefBubble && !a.briefBubble.done && !a.briefConcluded) {
|
|
3852
|
+
return [a.cotLastSent, a.cotText].filter((s) => !!s);
|
|
3853
|
+
}
|
|
3854
|
+
const s = a.liveStream;
|
|
3855
|
+
return s && !s.closed && !s.dead ? [s.acc, s.lastSent] : [];
|
|
3856
|
+
};
|
|
3857
|
+
const isOpenBubbleQuote = (target, quoted) => {
|
|
3858
|
+
const a = byTarget.get(target);
|
|
3859
|
+
if (!a)
|
|
3860
|
+
return false;
|
|
3861
|
+
const bodies = openBubbleBodies(a);
|
|
3862
|
+
if (bodies.length === 0)
|
|
3863
|
+
return false;
|
|
3864
|
+
const qc = quoteCanon(quoted);
|
|
3865
|
+
// 剥掉 URL 后什么都不剩 ⇒ 引用的只是链接头 + "…" 这类纯 chrome, 直接命中。
|
|
3866
|
+
if (!qc)
|
|
3867
|
+
return true;
|
|
3868
|
+
return bodies.some((b) => quoteCanon(b).includes(qc));
|
|
3869
|
+
};
|
|
3630
3870
|
// 一个 target 的完整画像。本 chat 的兄弟和外 chat 的 peer 走同一条,只有
|
|
3631
3871
|
// `self` / `address` 因观察者而异 —— 地址本来就是相对调用方说的话。
|
|
3632
3872
|
const peerInfoOf = async (t, self) => {
|
|
@@ -3756,7 +3996,7 @@ export const startMirror = (deps) => {
|
|
|
3756
3996
|
sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
|
|
3757
3997
|
});
|
|
3758
3998
|
if (!r.ok) {
|
|
3759
|
-
|
|
3999
|
+
endKeepaliveSwallow(a);
|
|
3760
4000
|
if (a.keepalive)
|
|
3761
4001
|
a.keepalive.pinging = false; // roll back so the next tick can retry
|
|
3762
4002
|
log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
|
|
@@ -3769,9 +4009,13 @@ export const startMirror = (deps) => {
|
|
|
3769
4009
|
// actual ping we injected; the assistant reply (expected: just "pong"), any
|
|
3770
4010
|
// tool calls, and usage are grafted on from the swallowed items in onItem;
|
|
3771
4011
|
// closed on the ping's turn_end (or the fail-safe). Kept out of chat.
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
4012
|
+
// The tail's keepalive_start (content match on the user line) may have
|
|
4013
|
+
// opened this turn already — only open when it hasn't.
|
|
4014
|
+
if (!a.keepaliveTurnId) {
|
|
4015
|
+
const turnId = newTurnId();
|
|
4016
|
+
a.keepaliveTurnId = turnId;
|
|
4017
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
4018
|
+
}
|
|
3775
4019
|
};
|
|
3776
4020
|
let keepaliveTicking = false;
|
|
3777
4021
|
const keepaliveTick = async () => {
|
|
@@ -3865,8 +4109,10 @@ export const startMirror = (deps) => {
|
|
|
3865
4109
|
// Stall recovery, decided by RULE only (no model self-judgment): the last
|
|
3866
4110
|
// transcript turn is a synthetic API-error/limit line, or the idle pane
|
|
3867
4111
|
// still shows an error banner ⇒ a turn died mid-work. Send the resume
|
|
3868
|
-
// instruction instead of the plain warmer.
|
|
3869
|
-
|
|
4112
|
+
// instruction instead of the plain warmer. A limit-parked episode is
|
|
4113
|
+
// excluded: retrying before resetsAt only buys another 429 line —
|
|
4114
|
+
// limitResumeTick owns that recovery, anchored on the reset moment.
|
|
4115
|
+
const stalled = kc.resumeOnStall && !a.limitResume && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
|
|
3870
4116
|
k.pinging = true;
|
|
3871
4117
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
3872
4118
|
await fireKeepalive(a, stalled);
|
|
@@ -3880,6 +4126,83 @@ export const startMirror = (deps) => {
|
|
|
3880
4126
|
}
|
|
3881
4127
|
};
|
|
3882
4128
|
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
4129
|
+
// ── Rate-limit auto-resume ────────────────────────────────────────────
|
|
4130
|
+
// Detection is poll-derived from the transcript tail every tick (limitResetAt:
|
|
4131
|
+
// "last message turn IS a 429 line"), so there is nothing to persist — a
|
|
4132
|
+
// daemon reload, a human retry in the TTY, or our own inject all converge
|
|
4133
|
+
// naturally: any newer turn makes detection stop firing and drops the state.
|
|
4134
|
+
const fmtClock = (ms) => {
|
|
4135
|
+
const d = new Date(ms);
|
|
4136
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
4137
|
+
};
|
|
4138
|
+
let limitResumeTicking = false;
|
|
4139
|
+
const limitResumeTick = async () => {
|
|
4140
|
+
const lr = cfg.wrc.mirror.limitResume;
|
|
4141
|
+
if (!lr.enabled || limitResumeTicking)
|
|
4142
|
+
return;
|
|
4143
|
+
limitResumeTicking = true;
|
|
4144
|
+
try {
|
|
4145
|
+
const now = Date.now();
|
|
4146
|
+
for (const a of byTarget.values()) {
|
|
4147
|
+
const resetsAt = limitResetAt(a.jsonlPath, now);
|
|
4148
|
+
if (!resetsAt) {
|
|
4149
|
+
a.limitResume = undefined;
|
|
4150
|
+
continue;
|
|
4151
|
+
}
|
|
4152
|
+
if (a.migrationWatcher)
|
|
4153
|
+
continue; // session rotating — judge after it settles
|
|
4154
|
+
if (a.keepaliveOff)
|
|
4155
|
+
continue; // /stop = deliberately quieted; honor it
|
|
4156
|
+
if (a.limitResume?.resetsAt !== resetsAt)
|
|
4157
|
+
a.limitResume = { resetsAt, notified: false, retried: false };
|
|
4158
|
+
const st = a.limitResume;
|
|
4159
|
+
const dueAt = resetsAt + lr.delaySec * 1000;
|
|
4160
|
+
if (!st.notified) {
|
|
4161
|
+
st.notified = true;
|
|
4162
|
+
log.info({ target: a.target, resetsAt, dueAt }, "limit-resume: parked, retry scheduled");
|
|
4163
|
+
sendStandalone(a, `[mirror] ⏳ 已触限额 (reset ${fmtClock(resetsAt)}) — ${fmtClock(dueAt)} 自动注入 \`${lr.text}\` 续跑`);
|
|
4164
|
+
}
|
|
4165
|
+
if (st.retried || now < dueAt)
|
|
4166
|
+
continue;
|
|
4167
|
+
if (a.liveStream && !a.liveStream.closed)
|
|
4168
|
+
continue; // mid typewriter — don't inject
|
|
4169
|
+
if (a.tmuxPane) {
|
|
4170
|
+
// Pane mode: a dead pane means the human closed shop — auto-respawning
|
|
4171
|
+
// a TUI at 3am would be a surprise, not a service. Busy means someone
|
|
4172
|
+
// (human, or another turn) is already driving; detection clears itself
|
|
4173
|
+
// once their turn lands. Spawn-mode has no pane and no such hazards —
|
|
4174
|
+
// inject's `claude --resume -p` fallback IS its normal operation.
|
|
4175
|
+
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
4176
|
+
continue;
|
|
4177
|
+
if (paneIsBusy(await capturePaneTail(a.tmuxPane, 16)))
|
|
4178
|
+
continue;
|
|
4179
|
+
}
|
|
4180
|
+
st.retried = true;
|
|
4181
|
+
// Unlike keepalive there is no swallow: the resumed work is real work —
|
|
4182
|
+
// the tail mirrors its output to chat normally. rememberInject only
|
|
4183
|
+
// suppresses the echo of the injected user line itself.
|
|
4184
|
+
rememberInject(lr.text);
|
|
4185
|
+
const r = await inject({
|
|
4186
|
+
text: lr.text, images: [], cfg,
|
|
4187
|
+
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "limit-resume" }),
|
|
4188
|
+
sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
|
|
4189
|
+
});
|
|
4190
|
+
if (!r.ok) {
|
|
4191
|
+
st.retried = false; // roll back so the next tick retries the inject
|
|
4192
|
+
log.warn({ target: a.target, reason: r.reason }, "limit-resume: inject failed");
|
|
4193
|
+
continue;
|
|
4194
|
+
}
|
|
4195
|
+
log.info({ target: a.target, resetsAt }, "limit-resume: resume injected");
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
catch (e) {
|
|
4199
|
+
log.warn({ err: e.message }, "limit-resume tick failed");
|
|
4200
|
+
}
|
|
4201
|
+
finally {
|
|
4202
|
+
limitResumeTicking = false;
|
|
4203
|
+
}
|
|
4204
|
+
};
|
|
4205
|
+
const limitResumeTimer = setInterval(() => void limitResumeTick(), 30_000);
|
|
3883
4206
|
return {
|
|
3884
4207
|
attach,
|
|
3885
4208
|
chatTargets,
|
|
@@ -3891,6 +4214,7 @@ export const startMirror = (deps) => {
|
|
|
3891
4214
|
peekTurns,
|
|
3892
4215
|
isBusy,
|
|
3893
4216
|
lastText,
|
|
4217
|
+
isOpenBubbleQuote,
|
|
3894
4218
|
status: () => {
|
|
3895
4219
|
const list = Array.from(bySessionId.values()).map((a) => ({
|
|
3896
4220
|
sessionId: a.sessionId,
|
|
@@ -3917,7 +4241,7 @@ export const startMirror = (deps) => {
|
|
|
3917
4241
|
},
|
|
3918
4242
|
targetForSession: (sessionId) => {
|
|
3919
4243
|
// Live attach is the fast path. Fall back to scanning persisted store:
|
|
3920
|
-
// an MCP tool (e.g. `
|
|
4244
|
+
// an MCP tool (e.g. `set_workspace`) called from a claude that isn't mirror-attached
|
|
3921
4245
|
// would otherwise miss here and silently retarget to defaultChat — that
|
|
3922
4246
|
// bug stranded pendingCwd on the wrong principal. Store keeps each
|
|
3923
4247
|
// target's sessionId in sync via migrate/attach/setPendingCwd writes.
|
|
@@ -4269,6 +4593,7 @@ export const startMirror = (deps) => {
|
|
|
4269
4593
|
shutdown: () => {
|
|
4270
4594
|
clearInterval(paneDriftTimer);
|
|
4271
4595
|
clearInterval(keepaliveTimer);
|
|
4596
|
+
clearInterval(limitResumeTimer);
|
|
4272
4597
|
for (const a of bySessionId.values()) {
|
|
4273
4598
|
if (a.outbound?.kind === "deferred")
|
|
4274
4599
|
clearTimeout(a.outbound.timer);
|
|
@@ -4572,10 +4897,10 @@ export const startMirror = (deps) => {
|
|
|
4572
4897
|
// and surface a standalone "cleared" so the user gets explicit
|
|
4573
4898
|
// feedback (the skip-stream path otherwise leaves WeCom silent).
|
|
4574
4899
|
if (armMigration) {
|
|
4575
|
-
// slashAckFirstLine
|
|
4900
|
+
// cwd 不随回执下发 (/pwd 可查); slashAckFirstLine 连 tip 也省去 (与 /new 一致)。
|
|
4576
4901
|
sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
|
|
4577
4902
|
? "cleared"
|
|
4578
|
-
: `cleared\n\n${
|
|
4903
|
+
: `cleared\n\n${randomTip()}`);
|
|
4579
4904
|
a.clearRebind = { baseline: preClearBaseline };
|
|
4580
4905
|
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
4581
4906
|
}
|