wezard 1.4.1 → 2.0.0
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 +12 -7
- package/README.md +227 -112
- package/cli/wezard.sh +6 -3
- package/dist/daemon/approval.js +9 -3
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-bridge.js.map +1 -1
- package/dist/daemon/chat-name.js +8 -14
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +94 -9
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +5 -7
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +135 -158
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +452 -156
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/mirror-bridge.js +284 -140
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +74 -0
- package/dist/daemon/model-select.js.map +1 -0
- package/dist/daemon/peers.js +10 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +27 -4
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/task-gate.js +97 -0
- package/dist/daemon/task-gate.js.map +1 -0
- package/dist/daemon/task-registry.js +132 -0
- package/dist/daemon/task-registry.js.map +1 -0
- package/dist/daemon/tasks.js +126 -54
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard-memory.js +57 -0
- package/dist/daemon/wizard-memory.js.map +1 -0
- package/dist/daemon/wizard.js +84 -36
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +138 -76
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +193 -96
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +31 -24
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +14 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/config.js +23 -4
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +14 -7
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +12 -5
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/role-render.js +52 -0
- package/dist/shared/role-render.js.map +1 -0
- package/dist/shared/role-view.js +210 -0
- package/dist/shared/role-view.js.map +1 -0
- package/dist/shared/schedule-spec.js +92 -116
- package/dist/shared/schedule-spec.js.map +1 -1
- package/dist/shared/session-label.js +103 -72
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/std.js +20 -0
- package/dist/shared/std.js.map +1 -1
- package/dist/shared/task-file.js +120 -0
- package/dist/shared/task-file.js.map +1 -0
- package/dist/shared/trigger.js +232 -0
- package/dist/shared/trigger.js.map +1 -0
- package/dist/shared/world.js +193 -0
- package/dist/shared/world.js.map +1 -0
- package/dist/svr/index.js +68 -3
- package/dist/svr/index.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +609 -147
- package/web/chat.js +1274 -215
|
@@ -17,20 +17,21 @@ import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync
|
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
19
19
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
20
|
-
import { sleep, truncateWithCount } from "../shared/std.js";
|
|
20
|
+
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
21
21
|
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
22
22
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
23
|
-
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
23
|
+
import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
24
24
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
25
25
|
import { noticeSuffixFor } from "./notices.js";
|
|
26
26
|
import { dangerOf } from "./danger.js";
|
|
27
27
|
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
28
|
+
import { wizardStore } from "./wizard.js";
|
|
28
29
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
29
30
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
30
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader,
|
|
31
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS } from "../shared/session-label.js";
|
|
31
32
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
32
33
|
import { randomTip } from "./tips.js";
|
|
33
|
-
import { chatBaseOf, chatNameOf, listChatNames,
|
|
34
|
+
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
34
35
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
35
36
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
36
37
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
@@ -677,9 +678,10 @@ const renderLine = (raw, deps) => {
|
|
|
677
678
|
};
|
|
678
679
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
679
680
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
680
|
-
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
681
|
-
//
|
|
682
|
-
|
|
681
|
+
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line and
|
|
682
|
+
// the chat-detail links linkTags hangs on it and on up to MAX_BODY_LINKS body
|
|
683
|
+
// mentions — each `(url)` runs ~200-250 bytes.
|
|
684
|
+
const TAG_HEADER_BUDGET = 256 * (1 + MAX_BODY_LINKS);
|
|
683
685
|
export const startMirrorTail = (deps) => {
|
|
684
686
|
const { log } = deps;
|
|
685
687
|
// A single logical tail follows ONE sessionId whose transcript file may move
|
|
@@ -799,11 +801,10 @@ export const startMirrorTail = (deps) => {
|
|
|
799
801
|
livePath: resolveLive,
|
|
800
802
|
};
|
|
801
803
|
};
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
// silently killed a whole chat. See TMUX_TIMEOUT_MS.)
|
|
804
|
+
const parsePaneList = (out) => new Map(out.split("\n").flatMap((line) => {
|
|
805
|
+
const [id, ...rest] = line.trim().split("\t");
|
|
806
|
+
return id?.startsWith("%") ? [[id, rest.join("\t")]] : [];
|
|
807
|
+
}));
|
|
807
808
|
// Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
|
|
808
809
|
// `input` so the hook-side and the jsonl-side compute the same signature even
|
|
809
810
|
// when the model emits keys in arbitrary order.
|
|
@@ -1581,8 +1582,8 @@ export const startMirror = (deps) => {
|
|
|
1581
1582
|
const detailCardFor = (s, target) => {
|
|
1582
1583
|
if (s.tools.length === 0)
|
|
1583
1584
|
return undefined;
|
|
1584
|
-
const
|
|
1585
|
-
const titlePrefix =
|
|
1585
|
+
const name = displayName(target);
|
|
1586
|
+
const titlePrefix = name ? `${labelFor(name)} .${name} · ` : "";
|
|
1586
1587
|
return {
|
|
1587
1588
|
card_type: "button_interaction",
|
|
1588
1589
|
main_title: { title: `${titlePrefix}本轮工具调用` },
|
|
@@ -1598,7 +1599,7 @@ export const startMirror = (deps) => {
|
|
|
1598
1599
|
const content = s.acc;
|
|
1599
1600
|
s.lastSent = content;
|
|
1600
1601
|
try {
|
|
1601
|
-
await client.replyStream(s.frame, s.streamId,
|
|
1602
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || " ", chatTurnUrl(s.target, s.turnId)), false);
|
|
1602
1603
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1603
1604
|
}
|
|
1604
1605
|
catch (e) {
|
|
@@ -1679,32 +1680,26 @@ export const startMirror = (deps) => {
|
|
|
1679
1680
|
};
|
|
1680
1681
|
// Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
|
|
1681
1682
|
// pushes from a single mirror stay ordered; different mirrors run in parallel.
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
const tag = tagOfKey(target);
|
|
1689
|
-
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
1690
|
-
};
|
|
1691
|
-
const withLinkedTag = (a, content, seq, turnId) => {
|
|
1692
|
-
const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
|
|
1693
|
-
if (prefix) {
|
|
1694
|
-
const seqBit = seq ? ` ${seq}` : "";
|
|
1695
|
-
return `${prefix}${seqBit}${headSep(content)}${content}`;
|
|
1696
|
-
}
|
|
1697
|
-
return withTagHeader(a.target, content, seq);
|
|
1698
|
-
};
|
|
1683
|
+
// 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
|
|
1684
|
+
// `emoji #tag` (withLinkedTagHeader 自己兜)。
|
|
1685
|
+
const chatTurnUrl = (target, turnId) => turnId
|
|
1686
|
+
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))
|
|
1687
|
+
: undefined;
|
|
1688
|
+
const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq);
|
|
1699
1689
|
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
|
|
1700
1690
|
// 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
|
|
1701
1691
|
// 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
|
|
1702
1692
|
// 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
|
|
1703
1693
|
const hasVisibleBody = (content) => parseTagHeader(content).body.length > 0;
|
|
1694
|
+
// 推到本轮的频道 (人从别的群叫它 → 回那个群)。私聊轮的正文 brief 下在上游
|
|
1695
|
+
// turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
|
|
1696
|
+
// `[wezard]` 系统提示 (它们回 home)。
|
|
1704
1697
|
const sendStandalone = (a, content) => {
|
|
1705
1698
|
if (!hasVisibleBody(content))
|
|
1706
1699
|
return;
|
|
1707
|
-
|
|
1700
|
+
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
1701
|
+
return;
|
|
1702
|
+
const chatId = replyChatId(a);
|
|
1708
1703
|
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1709
1704
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1710
1705
|
a.standalonePending = a.standalonePending
|
|
@@ -1724,7 +1719,7 @@ export const startMirror = (deps) => {
|
|
|
1724
1719
|
const sendRaw = (a, content) => {
|
|
1725
1720
|
if (!hasVisibleBody(content))
|
|
1726
1721
|
return;
|
|
1727
|
-
const chatId =
|
|
1722
|
+
const chatId = replyChatId(a);
|
|
1728
1723
|
a.standalonePending = a.standalonePending
|
|
1729
1724
|
.then(async () => {
|
|
1730
1725
|
try {
|
|
@@ -2045,18 +2040,15 @@ export const startMirror = (deps) => {
|
|
|
2045
2040
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
2046
2041
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
2047
2042
|
// target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
2048
|
-
const briefDetailLink = (turnId, target) =>
|
|
2049
|
-
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
2050
|
-
const tag = tagOfKey(target);
|
|
2051
|
-
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
2052
|
-
};
|
|
2043
|
+
const briefDetailLink = (turnId, target) => linkedTagHead(target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target)));
|
|
2053
2044
|
// ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
|
|
2054
2045
|
// 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
|
|
2055
2046
|
// store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
|
|
2056
2047
|
// 在看。群里保留的是需要在群里发生的事: WeCom 发起的轮次、审批/提问卡、
|
|
2057
2048
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2058
2049
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2059
|
-
|
|
2050
|
+
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2051
|
+
const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2060
2052
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2061
2053
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2062
2054
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2118,6 +2110,34 @@ export const startMirror = (deps) => {
|
|
|
2118
2110
|
a.pendingOrigin = undefined;
|
|
2119
2111
|
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
|
|
2120
2112
|
};
|
|
2113
|
+
/** 同上, 出处那一枚印章。 */
|
|
2114
|
+
const consumeFrom = (a) => {
|
|
2115
|
+
const p = a.pendingFrom;
|
|
2116
|
+
a.pendingFrom = undefined;
|
|
2117
|
+
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
|
|
2118
|
+
};
|
|
2119
|
+
/** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
|
|
2120
|
+
* home, 其余续写 (收口后的补写) 沿用上一轮。 */
|
|
2121
|
+
const consumeChannel = (a, reset) => {
|
|
2122
|
+
const p = a.pendingChannel;
|
|
2123
|
+
a.pendingChannel = undefined;
|
|
2124
|
+
if (p && Date.now() - p.at <= ORIGIN_TTL_MS) {
|
|
2125
|
+
a.channel = p.channel === baseOfKey(a.target) ? undefined : p.channel;
|
|
2126
|
+
a.speaker = p.speaker;
|
|
2127
|
+
}
|
|
2128
|
+
else if (reset) {
|
|
2129
|
+
a.channel = undefined;
|
|
2130
|
+
a.speaker = undefined;
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
/** 本轮落 turn 记录的频道字段。 */
|
|
2134
|
+
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
|
|
2135
|
+
/** 私聊轮: 回复只落 turn 记录。 */
|
|
2136
|
+
const isPrivate = (a) => a.channel === "";
|
|
2137
|
+
/** 本轮回复该推去的 chatId。 */
|
|
2138
|
+
const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
|
|
2139
|
+
// 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
|
|
2140
|
+
bindCardChannel((key) => byTarget.get(key)?.channel || "");
|
|
2121
2141
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2122
2142
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2123
2143
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
@@ -2126,7 +2146,8 @@ export const startMirror = (deps) => {
|
|
|
2126
2146
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2127
2147
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2128
2148
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2129
|
-
|
|
2149
|
+
consumeChannel(a, true);
|
|
2150
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
|
|
2130
2151
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2131
2152
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2132
2153
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2172,7 +2193,8 @@ export const startMirror = (deps) => {
|
|
|
2172
2193
|
const fromCli = a.pendingFromCli === true;
|
|
2173
2194
|
a.pendingFromCli = false;
|
|
2174
2195
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2175
|
-
|
|
2196
|
+
consumeChannel(a, fromCli);
|
|
2197
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
|
|
2176
2198
|
a.briefTurnId = turnId;
|
|
2177
2199
|
a.briefBubble = undefined;
|
|
2178
2200
|
a.briefIsSlash = false;
|
|
@@ -2181,7 +2203,7 @@ export const startMirror = (deps) => {
|
|
|
2181
2203
|
a.briefLastText = undefined;
|
|
2182
2204
|
clearCot(a);
|
|
2183
2205
|
// 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
|
|
2184
|
-
// (
|
|
2206
|
+
// (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
|
|
2185
2207
|
// 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
|
|
2186
2208
|
log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
|
|
2187
2209
|
};
|
|
@@ -2226,7 +2248,7 @@ export const startMirror = (deps) => {
|
|
|
2226
2248
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
2227
2249
|
a.lastConcludedAt = Date.now();
|
|
2228
2250
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2229
|
-
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2251
|
+
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${linkTags(a.target, body)}`, true);
|
|
2230
2252
|
}
|
|
2231
2253
|
else if (turnSilent(a)) {
|
|
2232
2254
|
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
@@ -2832,6 +2854,7 @@ export const startMirror = (deps) => {
|
|
|
2832
2854
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
2833
2855
|
&& !hasMirrorPlan(a.sessionId)) {
|
|
2834
2856
|
void runMirrorPlanFlow({
|
|
2857
|
+
cfg,
|
|
2835
2858
|
log: log.child({ sessionId: a.sessionId }),
|
|
2836
2859
|
client,
|
|
2837
2860
|
sessionId: a.sessionId,
|
|
@@ -2858,6 +2881,7 @@ export const startMirror = (deps) => {
|
|
|
2858
2881
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
2859
2882
|
&& !hasMirrorAskq(a.sessionId)) {
|
|
2860
2883
|
void runMirrorAskqFlow({
|
|
2884
|
+
cfg,
|
|
2861
2885
|
log: log.child({ sessionId: a.sessionId }),
|
|
2862
2886
|
client,
|
|
2863
2887
|
sessionId: a.sessionId,
|
|
@@ -3070,7 +3094,7 @@ export const startMirror = (deps) => {
|
|
|
3070
3094
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3071
3095
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3072
3096
|
: "";
|
|
3073
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
|
|
3097
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
|
|
3074
3098
|
const target = resolveTarget(targetOverride);
|
|
3075
3099
|
if (!target)
|
|
3076
3100
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3100,6 +3124,9 @@ export const startMirror = (deps) => {
|
|
|
3100
3124
|
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3101
3125
|
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3102
3126
|
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
3127
|
+
// Same carry rule again: an explicit spawn-time choice wins, otherwise this
|
|
3128
|
+
// wizard's existing policy (in-memory, or on disk across a reload) sticks.
|
|
3129
|
+
const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
|
|
3103
3130
|
if (prevByTarget)
|
|
3104
3131
|
detach(prevByTarget, "target reassigned");
|
|
3105
3132
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
@@ -3115,6 +3142,7 @@ export const startMirror = (deps) => {
|
|
|
3115
3142
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3116
3143
|
pendingCwd: carryPending,
|
|
3117
3144
|
model: carryModel,
|
|
3145
|
+
keepaliveDisabled: carryKeepaliveDisabled,
|
|
3118
3146
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3119
3147
|
standalonePending: Promise.resolve(),
|
|
3120
3148
|
recentToolSigs: new Map(),
|
|
@@ -3153,8 +3181,10 @@ export const startMirror = (deps) => {
|
|
|
3153
3181
|
cwd: a.runningCwd || undefined,
|
|
3154
3182
|
model: a.model || undefined,
|
|
3155
3183
|
pendingCwd: a.pendingCwd || undefined,
|
|
3184
|
+
cwdConfirmed: prevRec?.cwdConfirmed,
|
|
3156
3185
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
3157
3186
|
keepaliveOffAt: prevRec?.keepaliveOffAt,
|
|
3187
|
+
keepaliveDisabled: carryKeepaliveDisabled || undefined,
|
|
3158
3188
|
});
|
|
3159
3189
|
log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
|
|
3160
3190
|
return { ok: true, sessionId, jsonlPath, target };
|
|
@@ -3193,7 +3223,9 @@ export const startMirror = (deps) => {
|
|
|
3193
3223
|
s.add(a.sessionId);
|
|
3194
3224
|
return s;
|
|
3195
3225
|
};
|
|
3196
|
-
|
|
3226
|
+
// `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
|
|
3227
|
+
// 不传, 维持原来的逐个探活。
|
|
3228
|
+
const restoreFromStore = async (principal, panes) => {
|
|
3197
3229
|
const rec = deps.store.get(principal);
|
|
3198
3230
|
if (!rec)
|
|
3199
3231
|
return undefined;
|
|
@@ -3210,7 +3242,7 @@ export const startMirror = (deps) => {
|
|
|
3210
3242
|
// jsonl means "no input yet", NOT "rotated away" — healing it onto some
|
|
3211
3243
|
// unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
|
|
3212
3244
|
const storedPane = (rec.tmuxPane ?? "").trim();
|
|
3213
|
-
const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
3245
|
+
const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
3214
3246
|
if (!existsSync(jsonlAbs)) {
|
|
3215
3247
|
// First: the SAME-sid transcript may have merely relocated to a sibling
|
|
3216
3248
|
// project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
|
|
@@ -3263,8 +3295,9 @@ export const startMirror = (deps) => {
|
|
|
3263
3295
|
// is silently detached; that detached chat then never mirrors again. Runs at
|
|
3264
3296
|
// boot; the 3s drift follower maintains it thereafter.
|
|
3265
3297
|
if (livePane) {
|
|
3266
|
-
const
|
|
3267
|
-
|
|
3298
|
+
const paneCwd = panes
|
|
3299
|
+
? (panes.get(livePane) ?? "")
|
|
3300
|
+
: (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
|
|
3268
3301
|
if (paneCwd) {
|
|
3269
3302
|
// Encode under the backend that owns the bound transcript — comparing
|
|
3270
3303
|
// with the primary dialect would report a phantom drift for every
|
|
@@ -3299,6 +3332,8 @@ export const startMirror = (deps) => {
|
|
|
3299
3332
|
}
|
|
3300
3333
|
// Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
|
|
3301
3334
|
// session (attach preserved it on disk; this puts it back in memory).
|
|
3335
|
+
// keepaliveDisabled is carried by attach() itself (same rule as `model`);
|
|
3336
|
+
// only the transient `/stop` pause needs manual re-hydration here.
|
|
3302
3337
|
const restored = byTarget.get(principal);
|
|
3303
3338
|
if (restored && rec.keepaliveOff) {
|
|
3304
3339
|
restored.keepaliveOff = true;
|
|
@@ -3312,18 +3347,26 @@ export const startMirror = (deps) => {
|
|
|
3312
3347
|
const persistPause = (a) => {
|
|
3313
3348
|
const rec = deps.store.get(a.target);
|
|
3314
3349
|
if (rec)
|
|
3315
|
-
deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt });
|
|
3350
|
+
deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt, keepaliveDisabled: a.keepaliveDisabled });
|
|
3316
3351
|
};
|
|
3317
3352
|
// We re-attach lazily on demand too (see restoreFromStore in dispatch /
|
|
3318
3353
|
// hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
|
|
3319
3354
|
// even before any inbound arrives — e.g. claude finishes a long-running
|
|
3320
3355
|
// background task and writes assistant content to the jsonl while idle.
|
|
3356
|
+
const RESTORE_CONC = 8;
|
|
3321
3357
|
const persisted = deps.store.all();
|
|
3322
3358
|
const persistedKeys = Object.keys(persisted);
|
|
3323
3359
|
if (persistedKeys.length > 0) {
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3360
|
+
// 裸 fan-out 会把 2N 次 tmux 同时压下去 —— 几百个绑定就是七百个 client 一起
|
|
3361
|
+
// 撞 10s 超时, spawn 风暴顺带把事件循环压住, :17890 要 25s 才 bind, 而 CLI 的
|
|
3362
|
+
// reload 探测早放弃了 (「reload issued but /status not responding」的真身)。
|
|
3363
|
+
// 一张快照 + 封顶并发; await 立刻让出, startHttp 先绑端口。
|
|
3364
|
+
void (async () => {
|
|
3365
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
|
|
3366
|
+
const panes = snap.ok ? parsePaneList(snap.stdout) : undefined;
|
|
3367
|
+
await mapLimit(persistedKeys, RESTORE_CONC, (principal) => restoreFromStore(principal, panes));
|
|
3368
|
+
log.info({ restored: persistedKeys.length, panes: panes?.size ?? -1 }, "mirror: boot restore done");
|
|
3369
|
+
})();
|
|
3327
3370
|
}
|
|
3328
3371
|
else if (cfg.wrc.mirror.sessionId.trim()) {
|
|
3329
3372
|
// Pinned-sessionId fallback: only honor when the store is empty (otherwise
|
|
@@ -3867,9 +3910,12 @@ export const startMirror = (deps) => {
|
|
|
3867
3910
|
// 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
|
|
3868
3911
|
let charterOf;
|
|
3869
3912
|
const setCharterProvider = (fn) => { charterOf = fn; };
|
|
3870
|
-
|
|
3913
|
+
// `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
|
|
3914
|
+
// 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
|
|
3915
|
+
// 旧工作区里。
|
|
3916
|
+
const charterFor = (target, ctx) => {
|
|
3871
3917
|
try {
|
|
3872
|
-
return charterOf?.(target);
|
|
3918
|
+
return charterOf?.(target, ctx);
|
|
3873
3919
|
}
|
|
3874
3920
|
catch {
|
|
3875
3921
|
return undefined;
|
|
@@ -3919,7 +3965,7 @@ export const startMirror = (deps) => {
|
|
|
3919
3965
|
cwdOverride: eff,
|
|
3920
3966
|
cli: effCli,
|
|
3921
3967
|
model: opts?.model,
|
|
3922
|
-
systemPrompt: opts?.systemPrompt ?? charterFor(target),
|
|
3968
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
3923
3969
|
});
|
|
3924
3970
|
if (!r.ok)
|
|
3925
3971
|
return { ok: false, reason: r.reason };
|
|
@@ -3933,8 +3979,11 @@ export const startMirror = (deps) => {
|
|
|
3933
3979
|
// Explicit "" clears any carried-over pending — it has just been applied.
|
|
3934
3980
|
pendingCwd: "",
|
|
3935
3981
|
// Likewise explicit: `/new` starts a NEW session, so an unnamed model
|
|
3936
|
-
// means this CLI's default, not the dead pane's model.
|
|
3937
|
-
model
|
|
3982
|
+
// means this CLI's default, not the dead pane's model. Store what
|
|
3983
|
+
// spawnTmuxClaude actually confirmed via `/model` (r.model), not the
|
|
3984
|
+
// raw request — a later respawn's fast path then matches on try one.
|
|
3985
|
+
model: r.model ?? (opts?.model?.trim() ?? ""),
|
|
3986
|
+
keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
|
|
3938
3987
|
});
|
|
3939
3988
|
if (!att.ok)
|
|
3940
3989
|
return { ok: false, reason: att.reason };
|
|
@@ -3969,6 +4018,7 @@ export const startMirror = (deps) => {
|
|
|
3969
4018
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
3970
4019
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
3971
4020
|
cwd: baseA.runningCwd || undefined,
|
|
4021
|
+
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
3972
4022
|
pendingCwd: undefined,
|
|
3973
4023
|
});
|
|
3974
4024
|
}
|
|
@@ -3980,7 +4030,7 @@ export const startMirror = (deps) => {
|
|
|
3980
4030
|
}
|
|
3981
4031
|
if (!opts?.silent)
|
|
3982
4032
|
pushProjectInfo(target, "created");
|
|
3983
|
-
return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
|
|
4033
|
+
return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
|
|
3984
4034
|
};
|
|
3985
4035
|
// ── Clone ───────────────────────────────────────────────────────────
|
|
3986
4036
|
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
@@ -4022,7 +4072,7 @@ export const startMirror = (deps) => {
|
|
|
4022
4072
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4023
4073
|
if (!canInherit) {
|
|
4024
4074
|
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4025
|
-
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
|
|
4075
|
+
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
|
|
4026
4076
|
});
|
|
4027
4077
|
return { ...r, inherited: false };
|
|
4028
4078
|
}
|
|
@@ -4074,7 +4124,8 @@ export const startMirror = (deps) => {
|
|
|
4074
4124
|
tmuxSession: r.tmuxSession,
|
|
4075
4125
|
cwd: r.cwd,
|
|
4076
4126
|
pendingCwd: "",
|
|
4077
|
-
model: args.model?.trim() ?? "",
|
|
4127
|
+
model: r.model ?? (args.model?.trim() ?? ""),
|
|
4128
|
+
keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
|
|
4078
4129
|
});
|
|
4079
4130
|
if (!att.ok)
|
|
4080
4131
|
return { ok: false, reason: att.reason, inherited: false };
|
|
@@ -4089,7 +4140,7 @@ export const startMirror = (deps) => {
|
|
|
4089
4140
|
persistPause(spawned);
|
|
4090
4141
|
}
|
|
4091
4142
|
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4092
|
-
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true };
|
|
4143
|
+
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
|
|
4093
4144
|
};
|
|
4094
4145
|
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4095
4146
|
const sessionInfo = (target) => {
|
|
@@ -4146,6 +4197,7 @@ export const startMirror = (deps) => {
|
|
|
4146
4197
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
4147
4198
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
4148
4199
|
cwd: baseA.runningCwd || undefined,
|
|
4200
|
+
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
4149
4201
|
pendingCwd: baseA.pendingCwd || undefined,
|
|
4150
4202
|
});
|
|
4151
4203
|
log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, live base)");
|
|
@@ -4169,6 +4221,7 @@ export const startMirror = (deps) => {
|
|
|
4169
4221
|
tmuxSession: callerA.tmuxSession || undefined,
|
|
4170
4222
|
tmuxPane: callerA.tmuxPane || undefined,
|
|
4171
4223
|
cwd: callerA.runningCwd || undefined,
|
|
4224
|
+
cwdConfirmed: deps.store.get(target)?.cwdConfirmed,
|
|
4172
4225
|
pendingCwd: callerA.pendingCwd || undefined,
|
|
4173
4226
|
});
|
|
4174
4227
|
log.info({ target, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd }, "setPendingCwd (fallback: no base, wrote to caller)");
|
|
@@ -4182,6 +4235,32 @@ export const startMirror = (deps) => {
|
|
|
4182
4235
|
}
|
|
4183
4236
|
return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", runningCwd: "", pendingCwd: "" };
|
|
4184
4237
|
};
|
|
4238
|
+
// 「人说了就用这个目录」。一个新聊天的第一个 wizard 落在默认兜底目录里 —— 那不是
|
|
4239
|
+
// 选择, 只是没人说过。所以默认按「还要换」办 (charter 让它开工前先问一句), 直到人
|
|
4240
|
+
// 明说不用换。**只有这一个写入点**: 换去别的目录本身就是选择, 不必记一笔, 那时
|
|
4241
|
+
// runningCwd 已经不等于默认目录, cwdUnconfirmed 自然为假。
|
|
4242
|
+
// 寻址与 pendingCwd 同规 —— 写在 base 上 (聊天级), 没有 base 记录 (只有 tagged
|
|
4243
|
+
// 会话的聊天) 就退回调用方自己的那条。
|
|
4244
|
+
const confirmCwd = (target) => {
|
|
4245
|
+
const base = baseOfKey(target);
|
|
4246
|
+
const key = deps.store.get(base) ? base : deps.store.get(target) ? target : "";
|
|
4247
|
+
const rec = key ? deps.store.get(key) : undefined;
|
|
4248
|
+
if (!rec)
|
|
4249
|
+
return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", cwd: "" };
|
|
4250
|
+
deps.store.set(key, { ...rec, cwdConfirmed: true });
|
|
4251
|
+
const cwd = getCwd(target).runningCwd;
|
|
4252
|
+
log.info({ target, key, cwd }, "cwd confirmed by user");
|
|
4253
|
+
return { ok: true, cwd };
|
|
4254
|
+
};
|
|
4255
|
+
/** 该不该问人「要在哪个项目下干活」: 还蹲在默认兜底目录里, 而且人没说过就用它。 */
|
|
4256
|
+
const cwdUnconfirmed = (target, cwd) => {
|
|
4257
|
+
const base = baseOfKey(target);
|
|
4258
|
+
if (deps.store.get(base)?.cwdConfirmed || deps.store.get(target)?.cwdConfirmed)
|
|
4259
|
+
return false;
|
|
4260
|
+
// `cwd` 是调用方已经知道的「即将生效的目录」(spawn 前的宪章渲染)。没给就问现状。
|
|
4261
|
+
const eff = cwd?.trim() ? expandHome(cwd.trim()) : getCwd(target).runningCwd;
|
|
4262
|
+
return eff === expandedDefaultCwd;
|
|
4263
|
+
};
|
|
4185
4264
|
// ── Peer graph (sibling sessions of one chat) ────────────────────────
|
|
4186
4265
|
// A chat's sessions are exactly the keys sharing its base principal: the
|
|
4187
4266
|
// untagged default plus every `#tag`. Live attachments are the truth; the
|
|
@@ -4195,73 +4274,36 @@ export const startMirror = (deps) => {
|
|
|
4195
4274
|
const base = baseOfKey(target);
|
|
4196
4275
|
return allTargets().filter((k) => baseOfKey(k) === base);
|
|
4197
4276
|
};
|
|
4198
|
-
//
|
|
4199
|
-
//
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
return { ok: true, base };
|
|
4204
|
-
const known = listChatNames(cfg).map((c) => c.name);
|
|
4205
|
-
return {
|
|
4206
|
-
ok: false,
|
|
4207
|
-
reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
|
|
4208
|
-
candidates: known,
|
|
4209
|
-
};
|
|
4210
|
-
};
|
|
4211
|
-
// peer 寻址。地址是两级的(见 chat-name.ts):
|
|
4212
|
-
// "" 本 chat 的 default —— 自身语义不变;
|
|
4213
|
-
// `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
|
|
4214
|
-
// 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
|
|
4215
|
-
// `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
|
|
4216
|
-
// `daily#` daily 的 default 会话;
|
|
4217
|
-
// `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
|
|
4218
|
-
// 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
|
|
4219
|
-
// 的全称地址"——后者不需要动别人的会话。
|
|
4277
|
+
// peer 寻址。地址就是 wizard 的**全局名字** (`.fix` / `fix`), 与它住在哪个聊天无关:
|
|
4278
|
+
// "" 本 chat 的默认 wizard —— 自身语义不变;
|
|
4279
|
+
// `fix` / `.fix` 名册里叫这个名字的 wizard, 全局唯一, 不分聊天;
|
|
4280
|
+
// `chat:wr…#k` 全量 key (调试 / 老调用方);
|
|
4281
|
+
// `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
|
|
4220
4282
|
const resolvePeerTag = (self, ref) => {
|
|
4221
|
-
const
|
|
4222
|
-
const t = tag.trim();
|
|
4223
|
-
if (chat) {
|
|
4224
|
-
const c = resolveChatRef(chat);
|
|
4225
|
-
if (!c.ok)
|
|
4226
|
-
return c;
|
|
4227
|
-
const target = keyOf(c.base, t);
|
|
4228
|
-
if (allTargets().includes(target)) {
|
|
4229
|
-
return { ok: true, target, foreign: c.base !== baseOfKey(self) };
|
|
4230
|
-
}
|
|
4231
|
-
return {
|
|
4232
|
-
ok: false,
|
|
4233
|
-
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4234
|
-
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4235
|
-
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4236
|
-
candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4237
|
-
};
|
|
4238
|
-
}
|
|
4239
|
-
const local = keyOf(baseOfKey(self), t);
|
|
4240
|
-
if (!t)
|
|
4241
|
-
return { ok: true, target: local, foreign: false };
|
|
4283
|
+
const raw = stripSigil(ref);
|
|
4242
4284
|
const all = allTargets();
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
if (
|
|
4247
|
-
return { ok:
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4285
|
+
const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
|
|
4286
|
+
if (!raw)
|
|
4287
|
+
return hit(baseOfKey(self));
|
|
4288
|
+
if (/^(user|chat|group):/.test(raw)) {
|
|
4289
|
+
return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
|
|
4290
|
+
}
|
|
4291
|
+
const named = wizardStore()?.byName(raw)?.target;
|
|
4292
|
+
if (named && all.includes(named))
|
|
4293
|
+
return hit(named);
|
|
4294
|
+
if (named)
|
|
4295
|
+
return { ok: false, reason: `wizard '.${raw}' exists but its session is not running — respawn it (new_claude_session) or pick another` };
|
|
4296
|
+
const { chat, tag } = parsePeerRef(raw);
|
|
4297
|
+
if (chat) {
|
|
4298
|
+
const base = chatBaseOf(cfg, chat);
|
|
4299
|
+
const legacy = base ? keyOf(base, tag) : "";
|
|
4300
|
+
if (legacy && all.includes(legacy))
|
|
4301
|
+
return hit(legacy);
|
|
4260
4302
|
}
|
|
4261
4303
|
return {
|
|
4262
4304
|
ok: false,
|
|
4263
|
-
reason: `
|
|
4264
|
-
candidates:
|
|
4305
|
+
reason: `no wizard named '.${raw}' — wizard_roster lists every name`,
|
|
4306
|
+
candidates: all.map((k) => peerAddress(cfg, self, k)),
|
|
4265
4307
|
};
|
|
4266
4308
|
};
|
|
4267
4309
|
/** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
|
|
@@ -4327,6 +4369,7 @@ export const startMirror = (deps) => {
|
|
|
4327
4369
|
const pane = paneOf(t);
|
|
4328
4370
|
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
4329
4371
|
const tag = tagOfKey(t);
|
|
4372
|
+
const name = displayName(t);
|
|
4330
4373
|
let lastActivity = 0;
|
|
4331
4374
|
try {
|
|
4332
4375
|
if (jsonlPath)
|
|
@@ -4338,7 +4381,7 @@ export const startMirror = (deps) => {
|
|
|
4338
4381
|
tag,
|
|
4339
4382
|
chat: chatNameOf(cfg, t),
|
|
4340
4383
|
address: peerAddress(cfg, self, t),
|
|
4341
|
-
label:
|
|
4384
|
+
label: name ? labelFor(name) : "🧙",
|
|
4342
4385
|
sessionId: a?.sessionId || rec?.sessionId || "",
|
|
4343
4386
|
jsonlPath,
|
|
4344
4387
|
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
@@ -4355,16 +4398,43 @@ export const startMirror = (deps) => {
|
|
|
4355
4398
|
};
|
|
4356
4399
|
const byRecent = (x, y) => y.lastActivity - x.lastActivity;
|
|
4357
4400
|
const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
|
|
4358
|
-
// list_peers 的跨 chat
|
|
4359
|
-
//
|
|
4360
|
-
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
4361
|
-
// 只要给群起个名,不必回去改别人的 tag。
|
|
4401
|
+
// list_peers 的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
|
|
4402
|
+
// 全都叫得动 —— 地址就是 `.name`, 与住在哪个聊天无关。
|
|
4362
4403
|
const foreignPeers = async (self) => {
|
|
4363
4404
|
const selfBase = baseOfKey(self);
|
|
4364
|
-
|
|
4365
|
-
const
|
|
4366
|
-
|
|
4367
|
-
|
|
4405
|
+
// 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
|
|
4406
|
+
const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
|
|
4407
|
+
return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
4408
|
+
};
|
|
4409
|
+
// peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
|
|
4410
|
+
// 机器上 allTargets() 是几百个 —— 一次全量探活就是上千次 spawn, 足够打穿 fd
|
|
4411
|
+
// 上限、把 tmux server 拖垮, 守护进程跟着崩进重启循环 (CLAUDE.md 的 fd 那一条
|
|
4412
|
+
// 记的就是这个失效模式)。所以两道闸:
|
|
4413
|
+
// 1. 先用**不 spawn 的**证据排序 —— transcript 的 mtime, 一次 statSync;
|
|
4414
|
+
// 注册表里登记过的 wizard 无条件入选 (它们是有身份的那些, 也就那么几个)。
|
|
4415
|
+
// 2. 探活并发封顶, 且只探前 WORLD_PROBE 个。
|
|
4416
|
+
// 没被探到的照旧出现在名册里, 只是 busy/alive 按冷处理 —— 图上少一盏呼吸灯,
|
|
4417
|
+
// 远好过把整台机器上的会话一起搞停。
|
|
4418
|
+
const WORLD_PROBE = 48;
|
|
4419
|
+
const WORLD_CONC = 6;
|
|
4420
|
+
const worldPeers = async (self) => {
|
|
4421
|
+
const mtimeOf = (t) => {
|
|
4422
|
+
const p = jsonlOf(t);
|
|
4423
|
+
if (!p)
|
|
4424
|
+
return 0;
|
|
4425
|
+
try {
|
|
4426
|
+
return statSync(p).mtimeMs;
|
|
4427
|
+
}
|
|
4428
|
+
catch {
|
|
4429
|
+
return 0;
|
|
4430
|
+
}
|
|
4431
|
+
};
|
|
4432
|
+
const named = new Set(wizardStore()?.all().map((w) => w.target) ?? []);
|
|
4433
|
+
const ranked = allTargets()
|
|
4434
|
+
.map((t) => ({ t, ts: mtimeOf(t), named: named.has(t) }))
|
|
4435
|
+
.sort((a, b) => (a.named !== b.named ? (a.named ? -1 : 1) : b.ts - a.ts));
|
|
4436
|
+
const hot = ranked.slice(0, WORLD_PROBE).map((x) => x.t);
|
|
4437
|
+
return (await mapLimit(hot, WORLD_CONC, (t) => peerInfoOf(t, self))).sort(byRecent);
|
|
4368
4438
|
};
|
|
4369
4439
|
// Capture a few extra rows then compact away the TUI's blank padding, so
|
|
4370
4440
|
// `rows` counts lines the user actually cares about.
|
|
@@ -4481,6 +4551,8 @@ export const startMirror = (deps) => {
|
|
|
4481
4551
|
const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
|
|
4482
4552
|
const now = Date.now();
|
|
4483
4553
|
for (const a of byTarget.values()) {
|
|
4554
|
+
if (a.keepaliveDisabled)
|
|
4555
|
+
continue; // sticky spawn-time opt-out — never pings, never auto-resumes
|
|
4484
4556
|
if (!a.tmuxPane)
|
|
4485
4557
|
continue; // spawn-mode: no live pane to warm
|
|
4486
4558
|
if (a.migrationWatcher)
|
|
@@ -4715,6 +4787,7 @@ export const startMirror = (deps) => {
|
|
|
4715
4787
|
chatTargets,
|
|
4716
4788
|
peers,
|
|
4717
4789
|
foreignPeers,
|
|
4790
|
+
worldPeers,
|
|
4718
4791
|
resolvePeerTag,
|
|
4719
4792
|
chatRoster,
|
|
4720
4793
|
peekPane,
|
|
@@ -4904,6 +4977,16 @@ export const startMirror = (deps) => {
|
|
|
4904
4977
|
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
4905
4978
|
if (origin)
|
|
4906
4979
|
a.pendingOrigin = { origin, at: Date.now() };
|
|
4980
|
+
if (opts?.from)
|
|
4981
|
+
a.pendingFrom = { from: opts.from, at: Date.now() };
|
|
4982
|
+
if (opts?.channel !== undefined)
|
|
4983
|
+
a.pendingChannel = { channel: opts.channel, at: Date.now() };
|
|
4984
|
+
// 注入的那一行会被 recentInjects 当回显吞掉, 走不到 user_text —— 本轮的 query
|
|
4985
|
+
// (rolepage 上「谁对它说了什么」那一半) 只能在这里记。
|
|
4986
|
+
a.pendingBriefQuery = text;
|
|
4987
|
+
// 公开频道里说的话, 回复就该在群里看得见 —— 哪怕上一轮是人在 CLI 里敲的。
|
|
4988
|
+
if (opts?.channel)
|
|
4989
|
+
a.turnFromChat = true;
|
|
4907
4990
|
const sid = a.sessionId;
|
|
4908
4991
|
const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
|
|
4909
4992
|
if (!paneAlive) {
|
|
@@ -4911,7 +4994,7 @@ export const startMirror = (deps) => {
|
|
|
4911
4994
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4912
4995
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4913
4996
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4914
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
4997
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
|
|
4915
4998
|
if (!r.ok || !r.tmuxPane)
|
|
4916
4999
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4917
5000
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -5013,6 +5096,61 @@ export const startMirror = (deps) => {
|
|
|
5013
5096
|
log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
|
|
5014
5097
|
return { ok: true };
|
|
5015
5098
|
},
|
|
5099
|
+
reapIdle: async (ttlMs, skip) => {
|
|
5100
|
+
const now = Date.now();
|
|
5101
|
+
const reaped = [];
|
|
5102
|
+
const skipped = {};
|
|
5103
|
+
for (const target of allTargets()) {
|
|
5104
|
+
const a = byTarget.get(target);
|
|
5105
|
+
const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
|
|
5106
|
+
// 便宜闸门先行: transcript mtime 一次 statSync, 不 spawn —— 跑了几个
|
|
5107
|
+
// 月的机器上 allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd
|
|
5108
|
+
// (同 worldPeers 的 WORLD_PROBE 取舍)。还没写过 transcript 的 spawn-mode
|
|
5109
|
+
// 绑定没有活动证据, 不动它。
|
|
5110
|
+
const jsonl = jsonlOf(target);
|
|
5111
|
+
let mtime = 0;
|
|
5112
|
+
try {
|
|
5113
|
+
if (jsonl)
|
|
5114
|
+
mtime = statSync(jsonl).mtimeMs;
|
|
5115
|
+
}
|
|
5116
|
+
catch { /* 文件没了 */ }
|
|
5117
|
+
if (!mtime || now - mtime < ttlMs)
|
|
5118
|
+
continue;
|
|
5119
|
+
const veto = skip?.(target, sessionId);
|
|
5120
|
+
if (veto) {
|
|
5121
|
+
skipped[target] = veto;
|
|
5122
|
+
continue;
|
|
5123
|
+
}
|
|
5124
|
+
const pane = paneOf(target);
|
|
5125
|
+
if (!pane || !(await tmuxPaneAlive(pane)))
|
|
5126
|
+
continue; // pane 已死, 没什么可收的
|
|
5127
|
+
if (paneIsBusy(await capturePaneTail(pane, 12))) {
|
|
5128
|
+
skipped[target] = "busy";
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
// 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
|
|
5132
|
+
const w = await runTmux(["display-message", "-p", "-t", pane, "#{window_active}:#{session_attached}"]);
|
|
5133
|
+
const [wActive, sAttached] = w.stdout.trim().split(":");
|
|
5134
|
+
if (wActive === "1" && sAttached !== "0") {
|
|
5135
|
+
skipped[target] = "watched";
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
// 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
|
|
5139
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5140
|
+
await sleep(250);
|
|
5141
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5142
|
+
if (r.code !== 0) {
|
|
5143
|
+
skipped[target] = `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
|
|
5144
|
+
continue;
|
|
5145
|
+
}
|
|
5146
|
+
if (a)
|
|
5147
|
+
detach(a, "idle reap");
|
|
5148
|
+
// 不碰 store —— 绑定留着, 下一条 inbound 走 dead-pane `--resume` 复活。
|
|
5149
|
+
reaped.push(target);
|
|
5150
|
+
log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6) }, "mirror idle reap — pane killed, binding kept");
|
|
5151
|
+
}
|
|
5152
|
+
return { reaped, skipped };
|
|
5153
|
+
},
|
|
5016
5154
|
submitPane: async (target) => {
|
|
5017
5155
|
const a = byTarget.get(target);
|
|
5018
5156
|
if (!a)
|
|
@@ -5094,6 +5232,8 @@ export const startMirror = (deps) => {
|
|
|
5094
5232
|
},
|
|
5095
5233
|
getCwd,
|
|
5096
5234
|
setPendingCwd,
|
|
5235
|
+
cwdUnconfirmed,
|
|
5236
|
+
confirmCwd,
|
|
5097
5237
|
newSession,
|
|
5098
5238
|
cloneSession,
|
|
5099
5239
|
sessionInfo,
|
|
@@ -5113,7 +5253,11 @@ export const startMirror = (deps) => {
|
|
|
5113
5253
|
bySessionId.clear();
|
|
5114
5254
|
byTarget.clear();
|
|
5115
5255
|
},
|
|
5116
|
-
|
|
5256
|
+
currentChannel: (target) => {
|
|
5257
|
+
const a = byTarget.get(target);
|
|
5258
|
+
return a?.channel ?? baseOfKey(target);
|
|
5259
|
+
},
|
|
5260
|
+
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker }) => {
|
|
5117
5261
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
5118
5262
|
// from is the same string we registered as `target` on attach. After a
|
|
5119
5263
|
// daemon reload the in-memory map is empty; restore from the persisted
|
|
@@ -5149,6 +5293,8 @@ export const startMirror = (deps) => {
|
|
|
5149
5293
|
}
|
|
5150
5294
|
}
|
|
5151
5295
|
persistPause(a); // persist the pause/resume too, else a reload would revert it
|
|
5296
|
+
// 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
|
|
5297
|
+
a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
|
|
5152
5298
|
// Finalize prior live stream (if any) so this new turn renders into its
|
|
5153
5299
|
// own message bubble. Then open a fresh stream tied to the new frame and
|
|
5154
5300
|
// ack immediately so WeCom doesn't time out while inject queues.
|
|
@@ -5170,10 +5316,8 @@ export const startMirror = (deps) => {
|
|
|
5170
5316
|
if (a.liveStream && !a.liveStream.closed)
|
|
5171
5317
|
await finalizeStream(a, a.liveStream);
|
|
5172
5318
|
log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
|
|
5173
|
-
//
|
|
5174
|
-
|
|
5175
|
-
const tag = tagOfKey(a.target);
|
|
5176
|
-
const r = await newSession(a.target, tag || a.target);
|
|
5319
|
+
// The wizard's global name is the tmux window name (matches /new).
|
|
5320
|
+
const r = await newSession(a.target, displayName(a.target) || a.target);
|
|
5177
5321
|
if (!r.ok) {
|
|
5178
5322
|
try {
|
|
5179
5323
|
await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
|
|
@@ -5279,7 +5423,7 @@ export const startMirror = (deps) => {
|
|
|
5279
5423
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5280
5424
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5281
5425
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5282
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
5426
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
|
|
5283
5427
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5284
5428
|
a.tmuxPane = r.tmuxPane;
|
|
5285
5429
|
a.tmuxSession = r.tmuxSession;
|