wezard 2.4.0 → 2.5.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 +1 -1
- package/README.md +113 -57
- package/cli/wezard.sh +2 -0
- package/config.example.jsonc +7 -4
- package/dist/cli/wx.js +78 -0
- package/dist/cli/wx.js.map +1 -0
- package/dist/daemon/aibot-media.js +62 -0
- package/dist/daemon/aibot-media.js.map +1 -0
- package/dist/daemon/approval.js +30 -20
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/ask.js +3 -2
- package/dist/daemon/ask.js.map +1 -1
- package/dist/daemon/cc-session.js +27 -4
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/chat-log.js +2 -2
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/chat-name.js +6 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +25 -10
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/direction.js +53 -0
- package/dist/daemon/direction.js.map +1 -0
- package/dist/daemon/ilink.js +162 -0
- package/dist/daemon/ilink.js.map +1 -0
- package/dist/daemon/inbound.js +64 -18
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +415 -109
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +4 -3
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +6 -3
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/media.js +108 -0
- package/dist/daemon/media.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +111 -93
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/model-select.js +159 -54
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +59 -12
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/outbound.js +2 -1
- package/dist/daemon/outbound.js.map +1 -1
- package/dist/daemon/peers.js +46 -27
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +1 -1
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/reach.js +6 -0
- package/dist/daemon/reach.js.map +1 -0
- package/dist/daemon/receipts.js +149 -7
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +11 -4
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +157 -60
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +48 -7
- package/dist/daemon/steward-guard.js.map +1 -1
- package/dist/daemon/top-only-nudge.js +29 -0
- package/dist/daemon/top-only-nudge.js.map +1 -0
- package/dist/daemon/weixin-cmd.js +268 -0
- package/dist/daemon/weixin-cmd.js.map +1 -0
- package/dist/daemon/weixin-port.js +226 -0
- package/dist/daemon/weixin-port.js.map +1 -0
- package/dist/daemon/weixin.js +772 -0
- package/dist/daemon/weixin.js.map +1 -0
- package/dist/daemon/wizard.js +56 -18
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +43 -23
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +61 -20
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +9 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +16 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +3 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +45 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/json-map-store.js +1 -0
- package/dist/shared/json-map-store.js.map +1 -1
- package/dist/shared/reminder.js +1 -0
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +7 -4
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +116 -37
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/seen-log.js +26 -0
- package/dist/shared/seen-log.js.map +1 -0
- package/dist/shared/session-label.js +84 -31
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/turn-state.js +1 -1
- package/dist/shared/turn-state.js.map +1 -1
- package/dist/shared/world.js.map +1 -1
- package/dist/shared/wx-text.js +101 -0
- package/dist/shared/wx-text.js.map +1 -0
- package/dist/svr/index.js +5 -2
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +9 -6
- package/package.json +6 -2
- package/web/chat.css +32 -5
- package/web/chat.js +160 -92
|
@@ -19,7 +19,7 @@ import { join, dirname } from "node:path";
|
|
|
19
19
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
20
20
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
21
21
|
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
22
|
-
import { activeBackends, backendForPath,
|
|
22
|
+
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
23
23
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
24
24
|
import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
25
25
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
@@ -34,10 +34,11 @@ import { wizardStore } from "./wizard.js";
|
|
|
34
34
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
35
35
|
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
36
36
|
import { errText } from "./last-response.js";
|
|
37
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader,
|
|
37
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
|
|
38
38
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
39
|
+
import { isWxFrame } from "../shared/wx-text.js";
|
|
39
40
|
import { randomTip } from "./tips.js";
|
|
40
|
-
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
41
|
+
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress, stewardTierOf } from "./chat-name.js";
|
|
41
42
|
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
42
43
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
43
44
|
// PATH augmentation: launchd / systemd start the daemon
|
|
@@ -1393,10 +1394,10 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1393
1394
|
// 所以 codebuddy 走 C-v 必然失败 —— 改走 @<path> 文本提及,让 LLM
|
|
1394
1395
|
// 调 Read 工具读图(Read 支持图片,见 codebuddy tools-reference.md)。
|
|
1395
1396
|
// v2.52.4 之后 @<path> 不自动转 image block,但 LLM 看到路径会 Read。
|
|
1397
|
+
// dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
|
|
1398
|
+
// withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
|
|
1396
1399
|
if (backendName === "codebuddy") {
|
|
1397
|
-
|
|
1398
|
-
const textWithRefs = refs.length ? (text ? `${refs.join("\n")}\n${text}` : refs.join("\n")) : text;
|
|
1399
|
-
return injectViaTmuxText(target, textWithRefs, log, freshSpawn, witness);
|
|
1400
|
+
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness);
|
|
1400
1401
|
}
|
|
1401
1402
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1402
1403
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
@@ -1424,6 +1425,13 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1424
1425
|
}
|
|
1425
1426
|
return injectViaTmuxText(target, text, log, freshSpawn, witness);
|
|
1426
1427
|
};
|
|
1428
|
+
// 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
|
|
1429
|
+
// 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
|
|
1430
|
+
// 完全相同的字符串, 否则落盘行认不出是自己注入的。
|
|
1431
|
+
const withImageRefs = (text, images) => {
|
|
1432
|
+
const refs = images.map((p) => `@${p}`).join("\n");
|
|
1433
|
+
return refs ? (text ? `${refs}\n${text}` : refs) : text;
|
|
1434
|
+
};
|
|
1427
1435
|
// 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
|
|
1428
1436
|
// 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
|
|
1429
1437
|
//
|
|
@@ -1626,8 +1634,7 @@ const injectViaSpawn = (args) => {
|
|
|
1626
1634
|
// Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
|
|
1627
1635
|
// which Claude parses at submit time and inlines as image content blocks
|
|
1628
1636
|
// without a model-decided Read tool turn.
|
|
1629
|
-
const
|
|
1630
|
-
const finalText = refs ? (text ? `${refs}\n${text}` : refs) : text;
|
|
1637
|
+
const finalText = withImageRefs(text, images);
|
|
1631
1638
|
const cliArgs = [
|
|
1632
1639
|
"-p",
|
|
1633
1640
|
finalText,
|
|
@@ -1838,8 +1845,8 @@ export const startMirror = (deps) => {
|
|
|
1838
1845
|
const detailCardFor = (s, target) => {
|
|
1839
1846
|
if (s.tools.length === 0)
|
|
1840
1847
|
return undefined;
|
|
1841
|
-
const
|
|
1842
|
-
const titlePrefix =
|
|
1848
|
+
const badge = tagBadge(target).trim();
|
|
1849
|
+
const titlePrefix = badge ? `${badge} · ` : "";
|
|
1843
1850
|
return {
|
|
1844
1851
|
card_type: "button_interaction",
|
|
1845
1852
|
main_title: { title: `${titlePrefix}本轮工具调用` },
|
|
@@ -1855,7 +1862,7 @@ export const startMirror = (deps) => {
|
|
|
1855
1862
|
const content = s.acc;
|
|
1856
1863
|
s.lastSent = content;
|
|
1857
1864
|
try {
|
|
1858
|
-
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "
|
|
1865
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
|
|
1859
1866
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1860
1867
|
}
|
|
1861
1868
|
catch (e) {
|
|
@@ -1929,11 +1936,14 @@ export const startMirror = (deps) => {
|
|
|
1929
1936
|
turnId: newTurnId(),
|
|
1930
1937
|
frame, streamId,
|
|
1931
1938
|
target: a.target,
|
|
1939
|
+
...(a.channel ? { dest: a.channel } : {}),
|
|
1932
1940
|
acc: "", lastSent: "",
|
|
1933
1941
|
capped: false, closed: false, dead: false, cardSent: false,
|
|
1934
1942
|
tools: [], sawTool: false,
|
|
1935
1943
|
};
|
|
1936
|
-
|
|
1944
|
+
// 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
|
|
1945
|
+
if (!isWxFrame(frame))
|
|
1946
|
+
s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
|
|
1937
1947
|
log.info({ sessionId: a.sessionId, turnId: s.turnId, streamId }, "stream open");
|
|
1938
1948
|
return s;
|
|
1939
1949
|
};
|
|
@@ -1945,8 +1955,8 @@ export const startMirror = (deps) => {
|
|
|
1945
1955
|
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
|
|
1946
1956
|
: undefined;
|
|
1947
1957
|
// `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
|
|
1948
|
-
const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo);
|
|
1949
|
-
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (
|
|
1958
|
+
const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo, a.channel || undefined);
|
|
1959
|
+
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`.name:` / `[.name](url):`)
|
|
1950
1960
|
// 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
|
|
1951
1961
|
// 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
|
|
1952
1962
|
// 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
|
|
@@ -2282,7 +2292,7 @@ export const startMirror = (deps) => {
|
|
|
2282
2292
|
return;
|
|
2283
2293
|
a.cotLastSent = text;
|
|
2284
2294
|
try {
|
|
2285
|
-
await client.replyStream(b.frame, b.streamId,
|
|
2295
|
+
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
|
|
2286
2296
|
}
|
|
2287
2297
|
catch (e) {
|
|
2288
2298
|
log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
|
|
@@ -2304,7 +2314,8 @@ export const startMirror = (deps) => {
|
|
|
2304
2314
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
2305
2315
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
2306
2316
|
// wizard 名字作为 ww_uniq 传下去, 让同一个 wizard 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
2307
|
-
|
|
2317
|
+
// 头怎么写归 chatHead (默认 wizard 在 home 群: 企微只留名字链接, 微信不写头); 头与正文都空时留 `…`, 不发空气泡。
|
|
2318
|
+
const briefLine = (a, turnId, rest, dest = a.channel) => headed(chatHead(a.target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(a.target)), undefined, dest || undefined), rest) || "…";
|
|
2308
2319
|
// ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
|
|
2309
2320
|
// 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
|
|
2310
2321
|
// store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
|
|
@@ -2492,7 +2503,7 @@ export const startMirror = (deps) => {
|
|
|
2492
2503
|
};
|
|
2493
2504
|
/** 人那句被并进了一轮私聊: 它预建的气泡与轮次收口成一个链接 —— 答复不进群, 也不让气泡挂到超时。 */
|
|
2494
2505
|
const mergedAway = (a, q) => {
|
|
2495
|
-
void finishBubble(a, q.bubble,
|
|
2506
|
+
void finishBubble(a, q.bubble, briefLine(a, q.turnId, "(这句和一段私聊并成了一轮, 答复只在 rolepage)"), true);
|
|
2496
2507
|
recordTurnClose(q.turnId);
|
|
2497
2508
|
};
|
|
2498
2509
|
/** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
|
|
@@ -2556,15 +2567,17 @@ export const startMirror = (deps) => {
|
|
|
2556
2567
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
2557
2568
|
const turnId = newTurnId();
|
|
2558
2569
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2559
|
-
const bubble = { frame, streamId,
|
|
2570
|
+
const bubble = { frame, streamId, done: false };
|
|
2560
2571
|
const q = { turnId, bubble, isSlash };
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2572
|
+
// 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
|
|
2573
|
+
if (!isWxFrame(frame))
|
|
2574
|
+
bubble.hardTimer = setTimeout(() => {
|
|
2575
|
+
// WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
|
|
2576
|
+
// 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
|
|
2577
|
+
// 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
|
|
2578
|
+
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
2579
|
+
void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
2580
|
+
}, HARD_TIMEOUT_MS);
|
|
2568
2581
|
const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
2569
2582
|
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
2570
2583
|
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
@@ -2587,7 +2600,7 @@ export const startMirror = (deps) => {
|
|
|
2587
2600
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
2588
2601
|
// 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
|
|
2589
2602
|
try {
|
|
2590
|
-
await client.replyStream(frame, streamId,
|
|
2603
|
+
await client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false);
|
|
2591
2604
|
}
|
|
2592
2605
|
catch (e) {
|
|
2593
2606
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
@@ -2698,7 +2711,7 @@ export const startMirror = (deps) => {
|
|
|
2698
2711
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
2699
2712
|
a.lastConcludedAt = Date.now();
|
|
2700
2713
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2701
|
-
void finishBriefBubble(a,
|
|
2714
|
+
void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
|
|
2702
2715
|
}
|
|
2703
2716
|
else if (turnSilent(a)) {
|
|
2704
2717
|
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
@@ -3364,7 +3377,7 @@ export const startMirror = (deps) => {
|
|
|
3364
3377
|
// codebuddy 对 AskUserQuestion 不在提问时触发 PreToolUse hook — 先弹本地
|
|
3365
3378
|
// 面板, hook 只在面板被提交后才到达 (实测可延迟数小时)。这里从 jsonl 提前
|
|
3366
3379
|
// 看到 function_call, 直接驱动 vote 卡让远端可答; 点选后注入一段触发文本
|
|
3367
|
-
// 提交本地面板, hook 到达时以 deny+reason
|
|
3380
|
+
// 提交本地面板, hook 到达时以 deny+reason 覆盖答案 (claude 路径改走 allow+updatedInput)。
|
|
3368
3381
|
// claude 系 hook 即时触发, 不走此路 (否则双重发卡)。
|
|
3369
3382
|
if (c.name === "AskUserQuestion"
|
|
3370
3383
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
@@ -4455,32 +4468,14 @@ export const startMirror = (deps) => {
|
|
|
4455
4468
|
return undefined;
|
|
4456
4469
|
}
|
|
4457
4470
|
};
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
const paths = function* () {
|
|
4467
|
-
yield* head;
|
|
4468
|
-
yield* Object.values(deps.store.all())
|
|
4469
|
-
.map((r) => expandHome(r.jsonlPath))
|
|
4470
|
-
.filter((p) => !head.includes(p))
|
|
4471
|
-
.map((p) => ({ p, m: mtimeOf(p) }))
|
|
4472
|
-
.filter((x) => x.m > 0)
|
|
4473
|
-
.sort((x, y) => y.m - x.m)
|
|
4474
|
-
.map((x) => x.p);
|
|
4475
|
-
};
|
|
4476
|
-
for (const path of paths()) {
|
|
4477
|
-
if (!existsSync(path) || backendForPath(path).name !== name)
|
|
4478
|
-
continue;
|
|
4479
|
-
const id = lastModel(path);
|
|
4480
|
-
if (id)
|
|
4481
|
-
yield id;
|
|
4482
|
-
}
|
|
4483
|
-
}
|
|
4471
|
+
/** 管家该跑的那一档 (模型 + effort); 不是管家 = undefined。 */
|
|
4472
|
+
const stewardSpec = (target) => {
|
|
4473
|
+
const tier = stewardTierOf(cfg, target);
|
|
4474
|
+
return tier ? { model: cfg.models.tiers[tier].model || undefined, effort: cfg.models.tiers[tier].effort } : undefined;
|
|
4475
|
+
};
|
|
4476
|
+
/** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
|
|
4477
|
+
* 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
|
|
4478
|
+
const respawnSpec = (a) => a.model ? { model: a.model, effort: parseEffort(a.effort) } : stewardSpec(a.target) ?? { effort: parseEffort(a.effort) };
|
|
4484
4479
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4485
4480
|
const prev = byTarget.get(target);
|
|
4486
4481
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -4512,11 +4507,12 @@ export const startMirror = (deps) => {
|
|
|
4512
4507
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
4513
4508
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
4514
4509
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
4515
|
-
//
|
|
4516
|
-
//
|
|
4517
|
-
|
|
4518
|
-
const
|
|
4519
|
-
const
|
|
4510
|
+
// 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
|
|
4511
|
+
// effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
|
|
4512
|
+
// 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
|
|
4513
|
+
const steward = opts?.model?.trim() ? undefined : stewardSpec(target);
|
|
4514
|
+
const effModel = opts?.model?.trim() || steward?.model || undefined;
|
|
4515
|
+
const effEffort = opts?.effort ?? steward?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
|
|
4520
4516
|
if (prev?.tmuxPane) {
|
|
4521
4517
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
4522
4518
|
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
@@ -4534,7 +4530,6 @@ export const startMirror = (deps) => {
|
|
|
4534
4530
|
cwdOverride: eff,
|
|
4535
4531
|
cli: effCli,
|
|
4536
4532
|
model: effModel,
|
|
4537
|
-
knownModels: modelSightings(effCli, [target, base]),
|
|
4538
4533
|
effort: effEffort,
|
|
4539
4534
|
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
4540
4535
|
}).catch((e) => { inFlightSids.delete(freshSid); throw e; });
|
|
@@ -4687,7 +4682,6 @@ export const startMirror = (deps) => {
|
|
|
4687
4682
|
cwdOverride: parent.runningCwd,
|
|
4688
4683
|
cli: parentCli,
|
|
4689
4684
|
model,
|
|
4690
|
-
knownModels: modelSightings(parentCli, [args.parent]),
|
|
4691
4685
|
effort,
|
|
4692
4686
|
systemPrompt: args.systemPrompt,
|
|
4693
4687
|
});
|
|
@@ -4877,6 +4871,37 @@ export const startMirror = (deps) => {
|
|
|
4877
4871
|
return eff === expandedDefaultCwd;
|
|
4878
4872
|
};
|
|
4879
4873
|
// ── Peer graph (sibling sessions of one chat) ────────────────────────
|
|
4874
|
+
// 关一个会话的 pane。`drop` = /kill: 连绑定一起删, 下一条消息另起白板; 否则只是睡着 (同 pane cap 的 reap), 绑定留着、`--resume` 带全上下文复活。
|
|
4875
|
+
const downPane = async (target, drop) => {
|
|
4876
|
+
// paneOf (not byTarget.get) so a binding surviving only in the persisted
|
|
4877
|
+
// store — e.g. after a reload that couldn't re-attach — is still killable.
|
|
4878
|
+
const a = byTarget.get(target);
|
|
4879
|
+
const pane = a?.tmuxPane || paneOf(target);
|
|
4880
|
+
if (!a && !pane)
|
|
4881
|
+
return { ok: false, reason: "no session bound to target" };
|
|
4882
|
+
if (pane && (await tmuxPaneAlive(pane))) {
|
|
4883
|
+
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
4884
|
+
// flush its transcript before the TTY is yanked out from under it.
|
|
4885
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
4886
|
+
await sleep(250);
|
|
4887
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
4888
|
+
if (r.code !== 0)
|
|
4889
|
+
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
4890
|
+
}
|
|
4891
|
+
if (a)
|
|
4892
|
+
detach(a, drop ? "/kill" : "sleep");
|
|
4893
|
+
if (!drop) {
|
|
4894
|
+
log.info({ target, sessionId: a?.sessionId, pane }, "mirror sleep — pane killed, binding kept");
|
|
4895
|
+
return { ok: true };
|
|
4896
|
+
}
|
|
4897
|
+
// Drop the persisted record too — keeping it would let the next inbound
|
|
4898
|
+
// resurrect this very session via the dead-pane `--resume` self-heal,
|
|
4899
|
+
// which is the opposite of what /kill means. The chat then auto-spawns a
|
|
4900
|
+
// fresh session on its next message.
|
|
4901
|
+
deps.store.drop(target);
|
|
4902
|
+
log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
|
|
4903
|
+
return { ok: true };
|
|
4904
|
+
};
|
|
4880
4905
|
// A chat's sessions are exactly the keys sharing its base principal: the
|
|
4881
4906
|
// untagged default plus every `#tag`. Live attachments are the truth; the
|
|
4882
4907
|
// persisted store fills in cold bindings so a peer nobody has talked to since
|
|
@@ -5492,6 +5517,7 @@ export const startMirror = (deps) => {
|
|
|
5492
5517
|
isBusy,
|
|
5493
5518
|
idleNow,
|
|
5494
5519
|
parkedNow,
|
|
5520
|
+
idleVerdict,
|
|
5495
5521
|
untilIdle,
|
|
5496
5522
|
setModel: async (target, wanted, scope) => {
|
|
5497
5523
|
const a = byTarget.get(target);
|
|
@@ -5735,13 +5761,17 @@ export const startMirror = (deps) => {
|
|
|
5735
5761
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
5736
5762
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
5737
5763
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
5738
|
-
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,
|
|
5764
|
+
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, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
|
|
5739
5765
|
if (!r.ok || !r.tmuxPane)
|
|
5740
5766
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
5741
5767
|
a.tmuxPane = r.tmuxPane;
|
|
5742
5768
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
5743
5769
|
if (r.cwd)
|
|
5744
5770
|
a.runningCwd = r.cwd;
|
|
5771
|
+
// What the pane really landed on: a recorded string the CLI refused was
|
|
5772
|
+
// relaunched past, and must not cost the same extra launch next time.
|
|
5773
|
+
if (r.model !== undefined)
|
|
5774
|
+
a.model = r.model;
|
|
5745
5775
|
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, effort: a.effort || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
5746
5776
|
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5747
5777
|
fresh = true;
|
|
@@ -5822,32 +5852,8 @@ export const startMirror = (deps) => {
|
|
|
5822
5852
|
return { ok: false, reason: escReason };
|
|
5823
5853
|
return { ok: true, torndown, escOk, escReason: escReason || undefined };
|
|
5824
5854
|
},
|
|
5825
|
-
killPane:
|
|
5826
|
-
|
|
5827
|
-
// store — e.g. after a reload that couldn't re-attach — is still killable.
|
|
5828
|
-
const a = byTarget.get(target);
|
|
5829
|
-
const pane = a?.tmuxPane || paneOf(target);
|
|
5830
|
-
if (!a && !pane)
|
|
5831
|
-
return { ok: false, reason: "no session bound to target" };
|
|
5832
|
-
if (pane && (await tmuxPaneAlive(pane))) {
|
|
5833
|
-
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
5834
|
-
// flush its transcript before the TTY is yanked out from under it.
|
|
5835
|
-
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5836
|
-
await sleep(250);
|
|
5837
|
-
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5838
|
-
if (r.code !== 0)
|
|
5839
|
-
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5840
|
-
}
|
|
5841
|
-
if (a)
|
|
5842
|
-
detach(a, "/kill");
|
|
5843
|
-
// Drop the persisted record too — keeping it would let the next inbound
|
|
5844
|
-
// resurrect this very session via the dead-pane `--resume` self-heal,
|
|
5845
|
-
// which is the opposite of what /kill means. The chat then auto-spawns a
|
|
5846
|
-
// fresh session on its next message.
|
|
5847
|
-
deps.store.drop(target);
|
|
5848
|
-
log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
|
|
5849
|
-
return { ok: true };
|
|
5850
|
-
},
|
|
5855
|
+
killPane: (target) => downPane(target, true),
|
|
5856
|
+
sleepPane: (target) => downPane(target, false),
|
|
5851
5857
|
lastActivity: (target) => {
|
|
5852
5858
|
const jsonl = jsonlOf(target);
|
|
5853
5859
|
try {
|
|
@@ -6135,6 +6141,16 @@ export const startMirror = (deps) => {
|
|
|
6135
6141
|
// 任意 slash 命令 (/context, /cost, /status…): 产物走 skill_output standalone,
|
|
6136
6142
|
// 详情链接是纯噪声 —— brief 起 turn 时不推链接。
|
|
6137
6143
|
const isSlash = /^\/[a-z]/i.test(text.trim());
|
|
6144
|
+
// codebuddy 没有可用的剪贴板图片通道 (tmux 里 C-v 会被它自己的
|
|
6145
|
+
// syncTmuxToSystemClipboard 冲掉, 见 injectViaTmux 分流注释) —— 图片只能以
|
|
6146
|
+
// @<path> 文本进会话。既然如此, 问话 / 印章 / 回显去重 / 静默分叉跟随都按这个
|
|
6147
|
+
// 最终落盘形态记 (与 inject 拿到的同一个字符串); 否则落盘的 `@<path>` 行认不出
|
|
6148
|
+
// 是自己注入的: 被当成 CLI 手敲另开幽灵轮、触发 chatOriginOnly 吞回复, 而 IM 侧
|
|
6149
|
+
// 预建的 turn 永远等不到问话改写, rolepage 的入消息丢了图 (rolepage 一律按 CLI
|
|
6150
|
+
// 最终展示的那条消息渲染)。slash / /clear 判定仍用原始 text —— 命令不带图。
|
|
6151
|
+
const refsFirst = backendForPath(a.jsonlPath).name === "codebuddy" && (images?.length ?? 0) > 0;
|
|
6152
|
+
const effText = refsFirst ? withImageRefs(text, images ?? []) : text;
|
|
6153
|
+
const effImages = refsFirst ? [] : images;
|
|
6138
6154
|
// Auto-upgrade /clear → /new when the user has queued a project switch:
|
|
6139
6155
|
// a plain /clear would only rotate sessionId in the same pane, which sits
|
|
6140
6156
|
// in the OLD cwd. Killing+respawning is the only way to honor the switch.
|
|
@@ -6217,7 +6233,7 @@ export const startMirror = (deps) => {
|
|
|
6217
6233
|
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
6218
6234
|
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
6219
6235
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
6220
|
-
await startBriefTurn(a, frame, streamId, isSlash,
|
|
6236
|
+
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
|
|
6221
6237
|
}
|
|
6222
6238
|
else if (!armMigration && !eagerOpen) {
|
|
6223
6239
|
enterDeferred(a, frame, streamId);
|
|
@@ -6252,13 +6268,15 @@ export const startMirror = (deps) => {
|
|
|
6252
6268
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6253
6269
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
6254
6270
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
6255
|
-
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,
|
|
6271
|
+
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, ...respawnSpec(a), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
|
|
6256
6272
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
6257
6273
|
a.tmuxPane = r.tmuxPane;
|
|
6258
6274
|
a.tmuxSession = r.tmuxSession;
|
|
6259
6275
|
if (r.cwd)
|
|
6260
6276
|
a.runningCwd = r.cwd;
|
|
6261
6277
|
freshSpawn = true;
|
|
6278
|
+
if (r.model !== undefined)
|
|
6279
|
+
a.model = r.model; // 同 respawn-init: 记真落地的模型
|
|
6262
6280
|
deps.store.set(a.target, {
|
|
6263
6281
|
sessionId: sid,
|
|
6264
6282
|
jsonlPath: a.jsonlPath,
|
|
@@ -6302,7 +6320,7 @@ export const startMirror = (deps) => {
|
|
|
6302
6320
|
log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
|
|
6303
6321
|
return;
|
|
6304
6322
|
}
|
|
6305
|
-
rememberInject(
|
|
6323
|
+
rememberInject(effText);
|
|
6306
6324
|
// 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
|
|
6307
6325
|
// (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
|
|
6308
6326
|
// 发出后 2-3s 就落盘 —— tail 在 mute 期间读到的 item 会被 onItem 永久丢弃,
|
|
@@ -6311,7 +6329,7 @@ export const startMirror = (deps) => {
|
|
|
6311
6329
|
// 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
|
|
6312
6330
|
a.muteUntilInject = false;
|
|
6313
6331
|
const r = await inject({
|
|
6314
|
-
text, images, cfg, log: log.child({ principal, sessionId: sid }),
|
|
6332
|
+
text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
|
|
6315
6333
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
6316
6334
|
});
|
|
6317
6335
|
if (!r.ok) {
|
|
@@ -6359,7 +6377,7 @@ export const startMirror = (deps) => {
|
|
|
6359
6377
|
// stuck on the dead old jsonl. /clear via WeCom (armMigration) has its
|
|
6360
6378
|
// own watcher; skip spawn-mode (no shared pane to fork under us).
|
|
6361
6379
|
if (!armMigration && a.tmuxPane)
|
|
6362
|
-
armSilentForkRebind(a,
|
|
6380
|
+
armSilentForkRebind(a, effText);
|
|
6363
6381
|
// Confirm the /model picker. Early Enter (before the picker renders)
|
|
6364
6382
|
// would leave it unconfirmed, so settle first; a late Enter is a
|
|
6365
6383
|
// harmless no-op in the empty input box. On confirm, claude writes
|