wezard 1.4.2 → 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 +121 -67
- 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 -18
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +53 -12
- 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 +381 -192
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/mirror-bridge.js +221 -134
- 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 -38
- 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 +185 -108
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +26 -34
- 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 +12 -6
- package/dist/shared/detail-render.js.map +1 -1
- 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 +3 -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 +7 -5
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +67 -2
- package/dist/svr/index.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +407 -212
- package/web/chat.js +932 -321
|
@@ -20,7 +20,7 @@ import { augmentedPath } from "../shared/exec-path.js";
|
|
|
20
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";
|
|
@@ -28,10 +28,10 @@ import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
|
28
28
|
import { wizardStore } from "./wizard.js";
|
|
29
29
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
30
30
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
31
|
-
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";
|
|
32
32
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
33
33
|
import { randomTip } from "./tips.js";
|
|
34
|
-
import { chatBaseOf, chatNameOf, listChatNames,
|
|
34
|
+
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
35
35
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
36
36
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
37
37
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
@@ -678,9 +678,10 @@ const renderLine = (raw, deps) => {
|
|
|
678
678
|
};
|
|
679
679
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
680
680
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
681
|
-
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
682
|
-
//
|
|
683
|
-
|
|
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);
|
|
684
685
|
export const startMirrorTail = (deps) => {
|
|
685
686
|
const { log } = deps;
|
|
686
687
|
// A single logical tail follows ONE sessionId whose transcript file may move
|
|
@@ -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 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2124,6 +2116,28 @@ export const startMirror = (deps) => {
|
|
|
2124
2116
|
a.pendingFrom = undefined;
|
|
2125
2117
|
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
|
|
2126
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 || "");
|
|
2127
2141
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2128
2142
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2129
2143
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
@@ -2132,7 +2146,8 @@ export const startMirror = (deps) => {
|
|
|
2132
2146
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2133
2147
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2134
2148
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2135
|
-
|
|
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) });
|
|
2136
2151
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2137
2152
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2138
2153
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2178,7 +2193,8 @@ export const startMirror = (deps) => {
|
|
|
2178
2193
|
const fromCli = a.pendingFromCli === true;
|
|
2179
2194
|
a.pendingFromCli = false;
|
|
2180
2195
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2181
|
-
|
|
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) });
|
|
2182
2198
|
a.briefTurnId = turnId;
|
|
2183
2199
|
a.briefBubble = undefined;
|
|
2184
2200
|
a.briefIsSlash = false;
|
|
@@ -2187,7 +2203,7 @@ export const startMirror = (deps) => {
|
|
|
2187
2203
|
a.briefLastText = undefined;
|
|
2188
2204
|
clearCot(a);
|
|
2189
2205
|
// 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
|
|
2190
|
-
// (
|
|
2206
|
+
// (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
|
|
2191
2207
|
// 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
|
|
2192
2208
|
log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
|
|
2193
2209
|
};
|
|
@@ -2232,7 +2248,7 @@ export const startMirror = (deps) => {
|
|
|
2232
2248
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
2233
2249
|
a.lastConcludedAt = Date.now();
|
|
2234
2250
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2235
|
-
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2251
|
+
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${linkTags(a.target, body)}`, true);
|
|
2236
2252
|
}
|
|
2237
2253
|
else if (turnSilent(a)) {
|
|
2238
2254
|
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
@@ -2838,6 +2854,7 @@ export const startMirror = (deps) => {
|
|
|
2838
2854
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
2839
2855
|
&& !hasMirrorPlan(a.sessionId)) {
|
|
2840
2856
|
void runMirrorPlanFlow({
|
|
2857
|
+
cfg,
|
|
2841
2858
|
log: log.child({ sessionId: a.sessionId }),
|
|
2842
2859
|
client,
|
|
2843
2860
|
sessionId: a.sessionId,
|
|
@@ -2864,6 +2881,7 @@ export const startMirror = (deps) => {
|
|
|
2864
2881
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
2865
2882
|
&& !hasMirrorAskq(a.sessionId)) {
|
|
2866
2883
|
void runMirrorAskqFlow({
|
|
2884
|
+
cfg,
|
|
2867
2885
|
log: log.child({ sessionId: a.sessionId }),
|
|
2868
2886
|
client,
|
|
2869
2887
|
sessionId: a.sessionId,
|
|
@@ -3076,7 +3094,7 @@ export const startMirror = (deps) => {
|
|
|
3076
3094
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3077
3095
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3078
3096
|
: "";
|
|
3079
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
|
|
3097
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
|
|
3080
3098
|
const target = resolveTarget(targetOverride);
|
|
3081
3099
|
if (!target)
|
|
3082
3100
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3106,6 +3124,9 @@ export const startMirror = (deps) => {
|
|
|
3106
3124
|
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3107
3125
|
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3108
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);
|
|
3109
3130
|
if (prevByTarget)
|
|
3110
3131
|
detach(prevByTarget, "target reassigned");
|
|
3111
3132
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
@@ -3121,6 +3142,7 @@ export const startMirror = (deps) => {
|
|
|
3121
3142
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3122
3143
|
pendingCwd: carryPending,
|
|
3123
3144
|
model: carryModel,
|
|
3145
|
+
keepaliveDisabled: carryKeepaliveDisabled,
|
|
3124
3146
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3125
3147
|
standalonePending: Promise.resolve(),
|
|
3126
3148
|
recentToolSigs: new Map(),
|
|
@@ -3159,8 +3181,10 @@ export const startMirror = (deps) => {
|
|
|
3159
3181
|
cwd: a.runningCwd || undefined,
|
|
3160
3182
|
model: a.model || undefined,
|
|
3161
3183
|
pendingCwd: a.pendingCwd || undefined,
|
|
3184
|
+
cwdConfirmed: prevRec?.cwdConfirmed,
|
|
3162
3185
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
3163
3186
|
keepaliveOffAt: prevRec?.keepaliveOffAt,
|
|
3187
|
+
keepaliveDisabled: carryKeepaliveDisabled || undefined,
|
|
3164
3188
|
});
|
|
3165
3189
|
log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
|
|
3166
3190
|
return { ok: true, sessionId, jsonlPath, target };
|
|
@@ -3308,6 +3332,8 @@ export const startMirror = (deps) => {
|
|
|
3308
3332
|
}
|
|
3309
3333
|
// Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
|
|
3310
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.
|
|
3311
3337
|
const restored = byTarget.get(principal);
|
|
3312
3338
|
if (restored && rec.keepaliveOff) {
|
|
3313
3339
|
restored.keepaliveOff = true;
|
|
@@ -3321,7 +3347,7 @@ export const startMirror = (deps) => {
|
|
|
3321
3347
|
const persistPause = (a) => {
|
|
3322
3348
|
const rec = deps.store.get(a.target);
|
|
3323
3349
|
if (rec)
|
|
3324
|
-
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 });
|
|
3325
3351
|
};
|
|
3326
3352
|
// We re-attach lazily on demand too (see restoreFromStore in dispatch /
|
|
3327
3353
|
// hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
|
|
@@ -3884,9 +3910,12 @@ export const startMirror = (deps) => {
|
|
|
3884
3910
|
// 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
|
|
3885
3911
|
let charterOf;
|
|
3886
3912
|
const setCharterProvider = (fn) => { charterOf = fn; };
|
|
3887
|
-
|
|
3913
|
+
// `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
|
|
3914
|
+
// 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
|
|
3915
|
+
// 旧工作区里。
|
|
3916
|
+
const charterFor = (target, ctx) => {
|
|
3888
3917
|
try {
|
|
3889
|
-
return charterOf?.(target);
|
|
3918
|
+
return charterOf?.(target, ctx);
|
|
3890
3919
|
}
|
|
3891
3920
|
catch {
|
|
3892
3921
|
return undefined;
|
|
@@ -3936,7 +3965,7 @@ export const startMirror = (deps) => {
|
|
|
3936
3965
|
cwdOverride: eff,
|
|
3937
3966
|
cli: effCli,
|
|
3938
3967
|
model: opts?.model,
|
|
3939
|
-
systemPrompt: opts?.systemPrompt ?? charterFor(target),
|
|
3968
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
3940
3969
|
});
|
|
3941
3970
|
if (!r.ok)
|
|
3942
3971
|
return { ok: false, reason: r.reason };
|
|
@@ -3950,8 +3979,11 @@ export const startMirror = (deps) => {
|
|
|
3950
3979
|
// Explicit "" clears any carried-over pending — it has just been applied.
|
|
3951
3980
|
pendingCwd: "",
|
|
3952
3981
|
// Likewise explicit: `/new` starts a NEW session, so an unnamed model
|
|
3953
|
-
// means this CLI's default, not the dead pane's model.
|
|
3954
|
-
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,
|
|
3955
3987
|
});
|
|
3956
3988
|
if (!att.ok)
|
|
3957
3989
|
return { ok: false, reason: att.reason };
|
|
@@ -3986,6 +4018,7 @@ export const startMirror = (deps) => {
|
|
|
3986
4018
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
3987
4019
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
3988
4020
|
cwd: baseA.runningCwd || undefined,
|
|
4021
|
+
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
3989
4022
|
pendingCwd: undefined,
|
|
3990
4023
|
});
|
|
3991
4024
|
}
|
|
@@ -3997,7 +4030,7 @@ export const startMirror = (deps) => {
|
|
|
3997
4030
|
}
|
|
3998
4031
|
if (!opts?.silent)
|
|
3999
4032
|
pushProjectInfo(target, "created");
|
|
4000
|
-
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 };
|
|
4001
4034
|
};
|
|
4002
4035
|
// ── Clone ───────────────────────────────────────────────────────────
|
|
4003
4036
|
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
@@ -4039,7 +4072,7 @@ export const startMirror = (deps) => {
|
|
|
4039
4072
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4040
4073
|
if (!canInherit) {
|
|
4041
4074
|
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4042
|
-
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,
|
|
4043
4076
|
});
|
|
4044
4077
|
return { ...r, inherited: false };
|
|
4045
4078
|
}
|
|
@@ -4091,7 +4124,8 @@ export const startMirror = (deps) => {
|
|
|
4091
4124
|
tmuxSession: r.tmuxSession,
|
|
4092
4125
|
cwd: r.cwd,
|
|
4093
4126
|
pendingCwd: "",
|
|
4094
|
-
model: args.model?.trim() ?? "",
|
|
4127
|
+
model: r.model ?? (args.model?.trim() ?? ""),
|
|
4128
|
+
keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
|
|
4095
4129
|
});
|
|
4096
4130
|
if (!att.ok)
|
|
4097
4131
|
return { ok: false, reason: att.reason, inherited: false };
|
|
@@ -4106,7 +4140,7 @@ export const startMirror = (deps) => {
|
|
|
4106
4140
|
persistPause(spawned);
|
|
4107
4141
|
}
|
|
4108
4142
|
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4109
|
-
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 };
|
|
4110
4144
|
};
|
|
4111
4145
|
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4112
4146
|
const sessionInfo = (target) => {
|
|
@@ -4163,6 +4197,7 @@ export const startMirror = (deps) => {
|
|
|
4163
4197
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
4164
4198
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
4165
4199
|
cwd: baseA.runningCwd || undefined,
|
|
4200
|
+
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
4166
4201
|
pendingCwd: baseA.pendingCwd || undefined,
|
|
4167
4202
|
});
|
|
4168
4203
|
log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, live base)");
|
|
@@ -4186,6 +4221,7 @@ export const startMirror = (deps) => {
|
|
|
4186
4221
|
tmuxSession: callerA.tmuxSession || undefined,
|
|
4187
4222
|
tmuxPane: callerA.tmuxPane || undefined,
|
|
4188
4223
|
cwd: callerA.runningCwd || undefined,
|
|
4224
|
+
cwdConfirmed: deps.store.get(target)?.cwdConfirmed,
|
|
4189
4225
|
pendingCwd: callerA.pendingCwd || undefined,
|
|
4190
4226
|
});
|
|
4191
4227
|
log.info({ target, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd }, "setPendingCwd (fallback: no base, wrote to caller)");
|
|
@@ -4199,6 +4235,32 @@ export const startMirror = (deps) => {
|
|
|
4199
4235
|
}
|
|
4200
4236
|
return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", runningCwd: "", pendingCwd: "" };
|
|
4201
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
|
+
};
|
|
4202
4264
|
// ── Peer graph (sibling sessions of one chat) ────────────────────────
|
|
4203
4265
|
// A chat's sessions are exactly the keys sharing its base principal: the
|
|
4204
4266
|
// untagged default plus every `#tag`. Live attachments are the truth; the
|
|
@@ -4212,80 +4274,36 @@ export const startMirror = (deps) => {
|
|
|
4212
4274
|
const base = baseOfKey(target);
|
|
4213
4275
|
return allTargets().filter((k) => baseOfKey(k) === base);
|
|
4214
4276
|
};
|
|
4215
|
-
//
|
|
4216
|
-
//
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
return { ok: true, base };
|
|
4221
|
-
const known = listChatNames(cfg).map((c) => c.name);
|
|
4222
|
-
return {
|
|
4223
|
-
ok: false,
|
|
4224
|
-
reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
|
|
4225
|
-
candidates: known,
|
|
4226
|
-
};
|
|
4227
|
-
};
|
|
4228
|
-
// peer 寻址。地址是两级的(见 chat-name.ts):
|
|
4229
|
-
// "" 本 chat 的 default —— 自身语义不变;
|
|
4230
|
-
// `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
|
|
4231
|
-
// 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
|
|
4232
|
-
// `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
|
|
4233
|
-
// `daily` daily 这个聊天的 default 会话 —— 默认 wizard 的名字就是聊天名,
|
|
4234
|
-
// 所以裸聊天名就是它的地址 (`daily#` 这种老写法继续认);
|
|
4235
|
-
// `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
|
|
4236
|
-
// 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
|
|
4237
|
-
// 的全称地址"——后者不需要动别人的会话。
|
|
4277
|
+
// peer 寻址。地址就是 wizard 的**全局名字** (`.fix` / `fix`), 与它住在哪个聊天无关:
|
|
4278
|
+
// "" 本 chat 的默认 wizard —— 自身语义不变;
|
|
4279
|
+
// `fix` / `.fix` 名册里叫这个名字的 wizard, 全局唯一, 不分聊天;
|
|
4280
|
+
// `chat:wr…#k` 全量 key (调试 / 老调用方);
|
|
4281
|
+
// `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
|
|
4238
4282
|
const resolvePeerTag = (self, ref) => {
|
|
4239
|
-
const
|
|
4240
|
-
const t = tag.trim();
|
|
4241
|
-
if (chat) {
|
|
4242
|
-
const c = resolveChatRef(chat);
|
|
4243
|
-
if (!c.ok)
|
|
4244
|
-
return c;
|
|
4245
|
-
const target = keyOf(c.base, t);
|
|
4246
|
-
if (allTargets().includes(target)) {
|
|
4247
|
-
return { ok: true, target, foreign: c.base !== baseOfKey(self) };
|
|
4248
|
-
}
|
|
4249
|
-
return {
|
|
4250
|
-
ok: false,
|
|
4251
|
-
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4252
|
-
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4253
|
-
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4254
|
-
candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4255
|
-
};
|
|
4256
|
-
}
|
|
4257
|
-
const local = keyOf(baseOfKey(self), t);
|
|
4258
|
-
if (!t)
|
|
4259
|
-
return { ok: true, target: local, foreign: false };
|
|
4283
|
+
const raw = stripSigil(ref);
|
|
4260
4284
|
const all = allTargets();
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
if (
|
|
4265
|
-
return { ok:
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
return
|
|
4278
|
-
ok: false,
|
|
4279
|
-
reason: `chat '${t}' has no default session — address one of its tagged sessions, or create it with new_claude_session({ chat: '${t}', cwd })`,
|
|
4280
|
-
candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
4281
|
-
};
|
|
4282
|
-
}
|
|
4283
|
-
return { ok: false, reason: `no peer with tag '#${t}' — create one with new_claude_session, or address it in full as 'chatName#${t}' (list_chats shows the names)` };
|
|
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);
|
|
4284
4302
|
}
|
|
4285
4303
|
return {
|
|
4286
4304
|
ok: false,
|
|
4287
|
-
reason: `
|
|
4288
|
-
candidates:
|
|
4305
|
+
reason: `no wizard named '.${raw}' — wizard_roster lists every name`,
|
|
4306
|
+
candidates: all.map((k) => peerAddress(cfg, self, k)),
|
|
4289
4307
|
};
|
|
4290
4308
|
};
|
|
4291
4309
|
/** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
|
|
@@ -4351,6 +4369,7 @@ export const startMirror = (deps) => {
|
|
|
4351
4369
|
const pane = paneOf(t);
|
|
4352
4370
|
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
4353
4371
|
const tag = tagOfKey(t);
|
|
4372
|
+
const name = displayName(t);
|
|
4354
4373
|
let lastActivity = 0;
|
|
4355
4374
|
try {
|
|
4356
4375
|
if (jsonlPath)
|
|
@@ -4362,7 +4381,7 @@ export const startMirror = (deps) => {
|
|
|
4362
4381
|
tag,
|
|
4363
4382
|
chat: chatNameOf(cfg, t),
|
|
4364
4383
|
address: peerAddress(cfg, self, t),
|
|
4365
|
-
label:
|
|
4384
|
+
label: name ? labelFor(name) : "🧙",
|
|
4366
4385
|
sessionId: a?.sessionId || rec?.sessionId || "",
|
|
4367
4386
|
jsonlPath,
|
|
4368
4387
|
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
@@ -4379,16 +4398,13 @@ export const startMirror = (deps) => {
|
|
|
4379
4398
|
};
|
|
4380
4399
|
const byRecent = (x, y) => y.lastActivity - x.lastActivity;
|
|
4381
4400
|
const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
|
|
4382
|
-
// list_peers 的跨 chat
|
|
4383
|
-
//
|
|
4384
|
-
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
4385
|
-
// 只要给群起个名,不必回去改别人的 tag。
|
|
4401
|
+
// list_peers 的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
|
|
4402
|
+
// 全都叫得动 —— 地址就是 `.name`, 与住在哪个聊天无关。
|
|
4386
4403
|
const foreignPeers = async (self) => {
|
|
4387
4404
|
const selfBase = baseOfKey(self);
|
|
4388
|
-
|
|
4389
|
-
const
|
|
4390
|
-
|
|
4391
|
-
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
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);
|
|
4392
4408
|
};
|
|
4393
4409
|
// peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
|
|
4394
4410
|
// 机器上 allTargets() 是几百个 —— 一次全量探活就是上千次 spawn, 足够打穿 fd
|
|
@@ -4535,6 +4551,8 @@ export const startMirror = (deps) => {
|
|
|
4535
4551
|
const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
|
|
4536
4552
|
const now = Date.now();
|
|
4537
4553
|
for (const a of byTarget.values()) {
|
|
4554
|
+
if (a.keepaliveDisabled)
|
|
4555
|
+
continue; // sticky spawn-time opt-out — never pings, never auto-resumes
|
|
4538
4556
|
if (!a.tmuxPane)
|
|
4539
4557
|
continue; // spawn-mode: no live pane to warm
|
|
4540
4558
|
if (a.migrationWatcher)
|
|
@@ -4961,6 +4979,14 @@ export const startMirror = (deps) => {
|
|
|
4961
4979
|
a.pendingOrigin = { origin, at: Date.now() };
|
|
4962
4980
|
if (opts?.from)
|
|
4963
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;
|
|
4964
4990
|
const sid = a.sessionId;
|
|
4965
4991
|
const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
|
|
4966
4992
|
if (!paneAlive) {
|
|
@@ -4968,7 +4994,7 @@ export const startMirror = (deps) => {
|
|
|
4968
4994
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4969
4995
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4970
4996
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4971
|
-
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 }) });
|
|
4972
4998
|
if (!r.ok || !r.tmuxPane)
|
|
4973
4999
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4974
5000
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -5070,6 +5096,61 @@ export const startMirror = (deps) => {
|
|
|
5070
5096
|
log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
|
|
5071
5097
|
return { ok: true };
|
|
5072
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
|
+
},
|
|
5073
5154
|
submitPane: async (target) => {
|
|
5074
5155
|
const a = byTarget.get(target);
|
|
5075
5156
|
if (!a)
|
|
@@ -5151,6 +5232,8 @@ export const startMirror = (deps) => {
|
|
|
5151
5232
|
},
|
|
5152
5233
|
getCwd,
|
|
5153
5234
|
setPendingCwd,
|
|
5235
|
+
cwdUnconfirmed,
|
|
5236
|
+
confirmCwd,
|
|
5154
5237
|
newSession,
|
|
5155
5238
|
cloneSession,
|
|
5156
5239
|
sessionInfo,
|
|
@@ -5170,7 +5253,11 @@ export const startMirror = (deps) => {
|
|
|
5170
5253
|
bySessionId.clear();
|
|
5171
5254
|
byTarget.clear();
|
|
5172
5255
|
},
|
|
5173
|
-
|
|
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 }) => {
|
|
5174
5261
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
5175
5262
|
// from is the same string we registered as `target` on attach. After a
|
|
5176
5263
|
// daemon reload the in-memory map is empty; restore from the persisted
|
|
@@ -5206,6 +5293,8 @@ export const startMirror = (deps) => {
|
|
|
5206
5293
|
}
|
|
5207
5294
|
}
|
|
5208
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() };
|
|
5209
5298
|
// Finalize prior live stream (if any) so this new turn renders into its
|
|
5210
5299
|
// own message bubble. Then open a fresh stream tied to the new frame and
|
|
5211
5300
|
// ack immediately so WeCom doesn't time out while inject queues.
|
|
@@ -5227,10 +5316,8 @@ export const startMirror = (deps) => {
|
|
|
5227
5316
|
if (a.liveStream && !a.liveStream.closed)
|
|
5228
5317
|
await finalizeStream(a, a.liveStream);
|
|
5229
5318
|
log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
|
|
5230
|
-
//
|
|
5231
|
-
|
|
5232
|
-
const tag = tagOfKey(a.target);
|
|
5233
|
-
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);
|
|
5234
5321
|
if (!r.ok) {
|
|
5235
5322
|
try {
|
|
5236
5323
|
await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
|
|
@@ -5336,7 +5423,7 @@ export const startMirror = (deps) => {
|
|
|
5336
5423
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5337
5424
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5338
5425
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5339
|
-
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 }) });
|
|
5340
5427
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5341
5428
|
a.tmuxPane = r.tmuxPane;
|
|
5342
5429
|
a.tmuxSession = r.tmuxSession;
|