wezard 1.3.6 → 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 +1 -0
- package/dist/daemon/approval.js +33 -1
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/index.js +3 -2
- 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 +260 -13
- 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/shared/config.js +15 -0
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +21 -1
- 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 +1 -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);
|
|
@@ -776,6 +777,7 @@ export const startMirrorTail = (deps) => {
|
|
|
776
777
|
clearInterval(poll);
|
|
777
778
|
},
|
|
778
779
|
drain,
|
|
780
|
+
livePath: resolveLive,
|
|
779
781
|
};
|
|
780
782
|
};
|
|
781
783
|
// Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
|
|
@@ -1499,6 +1501,9 @@ export const startMirror = (deps) => {
|
|
|
1499
1501
|
if (s.closed)
|
|
1500
1502
|
return;
|
|
1501
1503
|
s.closed = true;
|
|
1504
|
+
// 非 brief 路径的父 turn 收口 — subagent turns 一并关 (brief 路径在
|
|
1505
|
+
// closeBriefTurn 关, 双关幂等)。
|
|
1506
|
+
closeSubagentTurns(a);
|
|
1502
1507
|
if (s.flushTimer) {
|
|
1503
1508
|
clearTimeout(s.flushTimer);
|
|
1504
1509
|
s.flushTimer = undefined;
|
|
@@ -1577,7 +1582,14 @@ export const startMirror = (deps) => {
|
|
|
1577
1582
|
}
|
|
1578
1583
|
return withSessionTag(a.target, content, seq);
|
|
1579
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;
|
|
1580
1590
|
const sendStandalone = (a, content) => {
|
|
1591
|
+
if (!hasVisibleBody(content))
|
|
1592
|
+
return;
|
|
1581
1593
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1582
1594
|
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1583
1595
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
@@ -1596,6 +1608,8 @@ export const startMirror = (deps) => {
|
|
|
1596
1608
|
};
|
|
1597
1609
|
// Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
|
|
1598
1610
|
const sendRaw = (a, content) => {
|
|
1611
|
+
if (!hasVisibleBody(content))
|
|
1612
|
+
return;
|
|
1599
1613
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1600
1614
|
a.standalonePending = a.standalonePending
|
|
1601
1615
|
.then(async () => {
|
|
@@ -1633,16 +1647,20 @@ export const startMirror = (deps) => {
|
|
|
1633
1647
|
};
|
|
1634
1648
|
// Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
|
|
1635
1649
|
// 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
|
|
1650
|
+
// parts 在 enqueue 时已过 hasVisibleBody, flush 端再过滤一遍是纵深防御 ——
|
|
1651
|
+
// 防的是绕过 enqueue 直写 buf 的未来代码路径。
|
|
1636
1652
|
const flushStandalone = (a) => {
|
|
1637
1653
|
const buf = a.standaloneBuf;
|
|
1638
1654
|
if (!buf)
|
|
1639
1655
|
return;
|
|
1640
|
-
const merged = buf.parts.join("\n\n");
|
|
1641
1656
|
a.standaloneBuf = undefined;
|
|
1657
|
+
const merged = buf.parts.filter((p) => hasVisibleBody(p)).join("\n\n");
|
|
1642
1658
|
if (merged)
|
|
1643
1659
|
sendStandalone(a, merged);
|
|
1644
1660
|
};
|
|
1645
1661
|
const enqueueStandalone = (a, content) => {
|
|
1662
|
+
if (!hasVisibleBody(content))
|
|
1663
|
+
return;
|
|
1646
1664
|
const ms = cfg.wrc.mirror.standaloneDebounceMs;
|
|
1647
1665
|
if (ms <= 0) {
|
|
1648
1666
|
sendStandalone(a, content);
|
|
@@ -2015,7 +2033,9 @@ export const startMirror = (deps) => {
|
|
|
2015
2033
|
a.briefConcluded = false;
|
|
2016
2034
|
a.briefLastText = undefined;
|
|
2017
2035
|
clearCot(a);
|
|
2018
|
-
|
|
2036
|
+
// 链接暂存, 不再单独发 — 让首条 standalone body 取走拼到前缀, 一条消息解决。
|
|
2037
|
+
// turn 始终会通过 concludeBriefTurn / closeBriefTurn 收口, header 不会泄漏。
|
|
2038
|
+
a.pendingBriefHeader = briefDetailLink(turnId, a.target);
|
|
2019
2039
|
log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
|
|
2020
2040
|
};
|
|
2021
2041
|
// 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
|
|
@@ -2026,19 +2046,28 @@ export const startMirror = (deps) => {
|
|
|
2026
2046
|
a.briefHadTool = true;
|
|
2027
2047
|
};
|
|
2028
2048
|
// 本轮结论落地, 只生效一次:
|
|
2029
|
-
// • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口;
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
2049
|
+
// • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口; ensureBriefTurn
|
|
2050
|
+
// 暂存的 header 跟着 bubble 收口一并丢 (气泡内已自带链接)。
|
|
2051
|
+
// • 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → 把暂存 header
|
|
2052
|
+
// 拼到 body 前缀一并 standalone, 一条消息含详情入口 + 正文。
|
|
2053
|
+
// • body 为空 → 一个字不发, 顺手清掉 header, 不留"只有链接"的空消息。
|
|
2032
2054
|
const concludeBriefTurn = (a, body) => {
|
|
2033
2055
|
const turnId = a.briefTurnId;
|
|
2034
|
-
if (!turnId || a.briefConcluded
|
|
2056
|
+
if (!turnId || a.briefConcluded)
|
|
2057
|
+
return;
|
|
2058
|
+
if (!body.trim()) {
|
|
2059
|
+
a.pendingBriefHeader = undefined;
|
|
2035
2060
|
return;
|
|
2061
|
+
}
|
|
2036
2062
|
a.briefConcluded = true;
|
|
2037
2063
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2038
2064
|
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2065
|
+
a.pendingBriefHeader = undefined;
|
|
2039
2066
|
}
|
|
2040
2067
|
else {
|
|
2041
|
-
|
|
2068
|
+
const header = a.pendingBriefHeader;
|
|
2069
|
+
a.pendingBriefHeader = undefined;
|
|
2070
|
+
sendStandalone(a, header ? `${header} ${body}` : body);
|
|
2042
2071
|
}
|
|
2043
2072
|
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
2044
2073
|
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
@@ -2060,8 +2089,123 @@ export const startMirror = (deps) => {
|
|
|
2060
2089
|
a.briefIsSlash = false;
|
|
2061
2090
|
a.briefConcluded = false;
|
|
2062
2091
|
a.briefLastText = undefined;
|
|
2092
|
+
a.pendingBriefHeader = undefined;
|
|
2093
|
+
closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
|
|
2063
2094
|
clearCot(a);
|
|
2064
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
|
+
};
|
|
2065
2209
|
// 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
|
|
2066
2210
|
//
|
|
2067
2211
|
// 关键约束: **一个 tmux 调用都不许有**。它的两个调用方 (`/stop` 和 inject
|
|
@@ -2080,6 +2224,7 @@ export const startMirror = (deps) => {
|
|
|
2080
2224
|
clearTimeout(a.softEnd);
|
|
2081
2225
|
a.softEnd = undefined;
|
|
2082
2226
|
}
|
|
2227
|
+
closeSubagentTurns(a); // brief/liveStream 都不在的收口路径也把 subagent 关掉
|
|
2083
2228
|
if (a.outbound) {
|
|
2084
2229
|
if (a.outbound.kind === "deferred")
|
|
2085
2230
|
clearTimeout(a.outbound.timer);
|
|
@@ -2192,9 +2337,14 @@ export const startMirror = (deps) => {
|
|
|
2192
2337
|
// loading 气泡。非 slash 场景的 skill_output 是用户可见的中间反馈, 走 standalone。
|
|
2193
2338
|
if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
|
|
2194
2339
|
void finishBriefBubble(a, item.body);
|
|
2340
|
+
a.pendingBriefHeader = undefined;
|
|
2195
2341
|
}
|
|
2196
2342
|
else {
|
|
2197
|
-
|
|
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);
|
|
2198
2348
|
}
|
|
2199
2349
|
return;
|
|
2200
2350
|
}
|
|
@@ -2373,6 +2523,17 @@ export const startMirror = (deps) => {
|
|
|
2373
2523
|
break;
|
|
2374
2524
|
a.recentToolSigs.delete(oldest);
|
|
2375
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
|
+
}
|
|
2376
2537
|
// codebuddy 的 ExitPlanMode 完全不过 PreToolUse hook (实测: 由
|
|
2377
2538
|
// interruption-service 本地对话框裁决, HookExecutor 零调用)。mirror 从
|
|
2378
2539
|
// jsonl 看到 function_call → 发计划审批卡; 点选后 send-keys 裁决本地
|
|
@@ -2605,6 +2766,9 @@ export const startMirror = (deps) => {
|
|
|
2605
2766
|
clearTimeout(a.standaloneBuf.timer);
|
|
2606
2767
|
flushStandalone(a);
|
|
2607
2768
|
}
|
|
2769
|
+
a.subagentWatch?.stop();
|
|
2770
|
+
a.subagentWatch = undefined;
|
|
2771
|
+
closeSubagentTurns(a);
|
|
2608
2772
|
a.tail.stop();
|
|
2609
2773
|
bySessionId.delete(a.sessionId);
|
|
2610
2774
|
if (byTarget.get(a.target) === a)
|
|
@@ -2659,7 +2823,7 @@ export const startMirror = (deps) => {
|
|
|
2659
2823
|
// collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
|
|
2660
2824
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
2661
2825
|
pendingCwd: carryPending,
|
|
2662
|
-
tail: { stop: () => undefined, drain: () => undefined }, // placeholder; replaced below
|
|
2826
|
+
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
2663
2827
|
standalonePending: Promise.resolve(),
|
|
2664
2828
|
recentToolSigs: new Map(),
|
|
2665
2829
|
};
|
|
@@ -2682,6 +2846,7 @@ export const startMirror = (deps) => {
|
|
|
2682
2846
|
// what lets a claude session and a codebuddy session be mirrored at once.
|
|
2683
2847
|
normalizeLine: backendForPath(jsonlPath).normalizeTranscriptLine,
|
|
2684
2848
|
});
|
|
2849
|
+
startSubagentsFor(a);
|
|
2685
2850
|
bySessionId.set(sessionId, a);
|
|
2686
2851
|
byTarget.set(target, a);
|
|
2687
2852
|
// Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
|
|
@@ -3065,6 +3230,8 @@ export const startMirror = (deps) => {
|
|
|
3065
3230
|
// path, not by CLI) — re-derive the dialect from the destination.
|
|
3066
3231
|
normalizeLine: backendForPath(newJsonlPath).normalizeTranscriptLine,
|
|
3067
3232
|
});
|
|
3233
|
+
// 会话轮换 = 新 <sid>/subagents/ 目录; 旧 run 的 turn 一并收口。
|
|
3234
|
+
startSubagentsFor(a);
|
|
3068
3235
|
deps.store.set(a.target, {
|
|
3069
3236
|
sessionId: newSessionId,
|
|
3070
3237
|
jsonlPath: newJsonlPath,
|
|
@@ -3942,8 +4109,10 @@ export const startMirror = (deps) => {
|
|
|
3942
4109
|
// Stall recovery, decided by RULE only (no model self-judgment): the last
|
|
3943
4110
|
// transcript turn is a synthetic API-error/limit line, or the idle pane
|
|
3944
4111
|
// still shows an error banner ⇒ a turn died mid-work. Send the resume
|
|
3945
|
-
// instruction instead of the plain warmer.
|
|
3946
|
-
|
|
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));
|
|
3947
4116
|
k.pinging = true;
|
|
3948
4117
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
3949
4118
|
await fireKeepalive(a, stalled);
|
|
@@ -3957,6 +4126,83 @@ export const startMirror = (deps) => {
|
|
|
3957
4126
|
}
|
|
3958
4127
|
};
|
|
3959
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);
|
|
3960
4206
|
return {
|
|
3961
4207
|
attach,
|
|
3962
4208
|
chatTargets,
|
|
@@ -4347,6 +4593,7 @@ export const startMirror = (deps) => {
|
|
|
4347
4593
|
shutdown: () => {
|
|
4348
4594
|
clearInterval(paneDriftTimer);
|
|
4349
4595
|
clearInterval(keepaliveTimer);
|
|
4596
|
+
clearInterval(limitResumeTimer);
|
|
4350
4597
|
for (const a of bySessionId.values()) {
|
|
4351
4598
|
if (a.outbound?.kind === "deferred")
|
|
4352
4599
|
clearTimeout(a.outbound.timer);
|