wezard 2.1.4 → 2.2.1
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/README.md +69 -24
- package/dist/daemon/cc-session.js +14 -1
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/danger.js +48 -7
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +28 -7
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +132 -0
- package/dist/daemon/handoff.js.map +1 -0
- package/dist/daemon/inbound.js +2 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +191 -99
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +136 -12
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +114 -34
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/receipts.js +190 -0
- package/dist/daemon/receipts.js.map +1 -0
- package/dist/daemon/route.js +92 -0
- package/dist/daemon/route.js.map +1 -0
- package/dist/daemon/wizard.js +38 -19
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +60 -25
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +101 -21
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +0 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +2 -9
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/config.js +6 -0
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +3 -2
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/keepalive.js +46 -0
- package/dist/shared/keepalive.js.map +1 -0
- package/dist/shared/reminder.js +13 -0
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +2 -1
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-search.js +100 -0
- package/dist/shared/role-search.js.map +1 -0
- package/dist/shared/role-view.js +85 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/world.js +38 -8
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +1 -1
- package/dist/svr/index.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +415 -333
- package/web/chat.js +899 -741
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Caveat: the user shouldn't be hammering the same session in their local TTY
|
|
14
14
|
// while a `--resume` injection is in flight; Claude Code locks aren't strict.
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
|
-
import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
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";
|
|
@@ -26,15 +26,17 @@ import { noticeSuffixFor } from "./notices.js";
|
|
|
26
26
|
import { dangerOf } from "./danger.js";
|
|
27
27
|
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
28
28
|
import { selectModel } from "./model-select.js";
|
|
29
|
-
import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince } from "./cc-session.js";
|
|
29
|
+
import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince, watchRegistry } from "./cc-session.js";
|
|
30
|
+
import { waitForIdle } from "./graph.js";
|
|
30
31
|
import { wizardStore } from "./wizard.js";
|
|
31
32
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
32
|
-
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
33
|
+
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
33
34
|
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS } from "../shared/session-label.js";
|
|
34
35
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
35
36
|
import { randomTip } from "./tips.js";
|
|
36
37
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
37
|
-
import { stripAnsi, paneIsBusy, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing,
|
|
38
|
+
import { stripAnsi, paneIsBusy, transcriptStalled, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
39
|
+
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
38
40
|
// PATH augmentation: launchd / systemd start the daemon
|
|
39
41
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
40
42
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
@@ -433,6 +435,43 @@ const renderToolUseGroupBody = (calls, deps) => {
|
|
|
433
435
|
});
|
|
434
436
|
return [header, ...lines].join("\n");
|
|
435
437
|
};
|
|
438
|
+
// 从尾往前, 逐个产出含 needle 的整行 —— 只切命中的那几行, 不把整份 transcript 拆成数组。
|
|
439
|
+
function* linesWithFromEnd(text, needle) {
|
|
440
|
+
let at = text.lastIndexOf(needle);
|
|
441
|
+
while (at >= 0) {
|
|
442
|
+
const s = text.lastIndexOf("\n", at) + 1;
|
|
443
|
+
const e = text.indexOf("\n", at);
|
|
444
|
+
yield text.slice(s, e < 0 ? undefined : e);
|
|
445
|
+
at = s > 0 ? text.lastIndexOf(needle, s - 1) : -1;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// tail 漏看了某次 tool_use (daemon 停机窗口里落盘的行 —— 恢复后的 tail 从 EOF 起读;
|
|
449
|
+
// 一个挂了几小时的审批最常撞上), 结果却到了: 回 transcript 按 id 把调用本身捞回来。
|
|
450
|
+
const recoverToolUse = (toolUseId, deps) => {
|
|
451
|
+
let text;
|
|
452
|
+
try {
|
|
453
|
+
text = readFileSync(deps.jsonlPath, "utf8");
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
for (const raw of linesWithFromEnd(text, toolUseId)) {
|
|
459
|
+
let line;
|
|
460
|
+
try {
|
|
461
|
+
line = JSON.parse(raw);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (deps.normalizeLine)
|
|
467
|
+
line = deps.normalizeLine(line);
|
|
468
|
+
const blocks = line?.type === "assistant" ? line.message?.content : undefined;
|
|
469
|
+
const b = Array.isArray(blocks) ? blocks.find((x) => x?.type === "tool_use" && x.id === toolUseId) : undefined;
|
|
470
|
+
if (b)
|
|
471
|
+
return { toolUseId, name: b.name ?? "tool", input: b.input };
|
|
472
|
+
}
|
|
473
|
+
return undefined;
|
|
474
|
+
};
|
|
436
475
|
// Render one transcript line into tagged items. Caller decides batching.
|
|
437
476
|
const renderLine = (raw, deps) => {
|
|
438
477
|
let line;
|
|
@@ -533,6 +572,12 @@ const renderLine = (raw, deps) => {
|
|
|
533
572
|
continue;
|
|
534
573
|
const toolUseId = b.tool_use_id ?? "";
|
|
535
574
|
const full = truncateWithCount(raw, DETAIL_RESULT_MAX);
|
|
575
|
+
// 孤儿 result: 先补发它的调用, 下游 (turn store / 气泡) 就和 tail 当场看到时一样配对。
|
|
576
|
+
const lost = toolUseId && deps.includeTools && !knowsToolUse(toolUseId) ? recoverToolUse(toolUseId, deps) : undefined;
|
|
577
|
+
if (lost) {
|
|
578
|
+
recordTool({ id: lost.toolUseId, toolName: lost.name, toolInput: lost.input, sessionId: deps.sessionId, target: deps.target });
|
|
579
|
+
out.push({ kind: "tool_use", calls: [lost], body: renderToolUseGroupBody([lost], deps) });
|
|
580
|
+
}
|
|
536
581
|
// 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
|
|
537
582
|
// includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
|
|
538
583
|
// turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
|
|
@@ -3596,17 +3641,18 @@ export const startMirror = (deps) => {
|
|
|
3596
3641
|
const RESTORE_CONC = 8;
|
|
3597
3642
|
const persisted = deps.store.all();
|
|
3598
3643
|
const persistedKeys = Object.keys(persisted);
|
|
3644
|
+
let restored = Promise.resolve();
|
|
3599
3645
|
if (persistedKeys.length > 0) {
|
|
3600
3646
|
// 裸 fan-out 会把 2N 次 tmux 同时压下去 —— 几百个绑定就是七百个 client 一起
|
|
3601
3647
|
// 撞 10s 超时, spawn 风暴顺带把事件循环压住, :17890 要 25s 才 bind, 而 CLI 的
|
|
3602
3648
|
// reload 探测早放弃了 (「reload issued but /status not responding」的真身)。
|
|
3603
3649
|
// 一张快照 + 封顶并发; await 立刻让出, startHttp 先绑端口。
|
|
3604
|
-
|
|
3650
|
+
restored = (async () => {
|
|
3605
3651
|
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
|
|
3606
3652
|
const panes = snap.ok ? parsePaneList(snap.stdout) : undefined;
|
|
3607
3653
|
await mapLimit(persistedKeys, RESTORE_CONC, (principal) => restoreFromStore(principal, panes));
|
|
3608
3654
|
log.info({ restored: persistedKeys.length, panes: panes?.size ?? -1 }, "mirror: boot restore done");
|
|
3609
|
-
})();
|
|
3655
|
+
})().catch((e) => { log.warn({ err: e.message }, "mirror: boot restore failed"); });
|
|
3610
3656
|
}
|
|
3611
3657
|
else if (cfg.wrc.mirror.sessionId.trim()) {
|
|
3612
3658
|
// Pinned-sessionId fallback: only honor when the store is empty (otherwise
|
|
@@ -4243,7 +4289,7 @@ export const startMirror = (deps) => {
|
|
|
4243
4289
|
// Pause keepalive like /stop; the first real turn (WeCom inbound, or the
|
|
4244
4290
|
// pane going busy after the resume grace) re-earns the budget. Mirrors
|
|
4245
4291
|
// interruptPane so /new can't strand a ping loop on an idle blank session.
|
|
4246
|
-
spawned.keepaliveOff =
|
|
4292
|
+
spawned.keepaliveOff = !opts?.warm;
|
|
4247
4293
|
spawned.keepaliveOffAt = Date.now();
|
|
4248
4294
|
spawned.keepalive = undefined; // re-anchors cleanly on resume
|
|
4249
4295
|
persistPause(spawned);
|
|
@@ -4573,6 +4619,57 @@ export const startMirror = (deps) => {
|
|
|
4573
4619
|
// and it keeps a graph step from blocking forever on a session that vanished.
|
|
4574
4620
|
const paneBusy = async (pane) => !!pane && (await tmuxPaneAlive(pane)) && paneIsBusy(await capturePaneTail(pane, 12));
|
|
4575
4621
|
const isBusy = (target) => paneBusy(paneOf(target));
|
|
4622
|
+
// 「它这一轮结束了、可以投了」。注册表认得这个 pane 时以它为准: status 在整轮里
|
|
4623
|
+
// (工具调用之间、PreToolUse 审批 hook 挂着时) 一直是 busy, 只在轮结束那一刻翻成
|
|
4624
|
+
// idle —— 所以不必抽样 spinner, 也不必连数几次安静来排除工具间隙。idle 却悬着工具
|
|
4625
|
+
// 调用 = 停在本地审批框上等人点, 不算闲。注册表不认得 (codebuddy / 旧版 CC /
|
|
4626
|
+
// 会话已退出) 返回 undefined, 交给 pane 裁决。
|
|
4627
|
+
const idleVerdict = (target) => {
|
|
4628
|
+
const pane = paneOf(target);
|
|
4629
|
+
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
4630
|
+
if (!live)
|
|
4631
|
+
return undefined;
|
|
4632
|
+
if (live.status === "busy")
|
|
4633
|
+
return false;
|
|
4634
|
+
if (live.status !== "idle")
|
|
4635
|
+
return undefined;
|
|
4636
|
+
const bound = jsonlOf(target);
|
|
4637
|
+
// resume / fork 会换 sid: 悬着的工具调用记在注册表报的当前那份 transcript 里。
|
|
4638
|
+
const jsonl = bound ? join(dirname(bound), `${live.sessionId}.jsonl`) : "";
|
|
4639
|
+
return !(jsonl && existsSync(jsonl) && openToolUses(jsonl).length > 0);
|
|
4640
|
+
};
|
|
4641
|
+
const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
|
|
4642
|
+
// 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
|
|
4643
|
+
// 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
|
|
4644
|
+
const IDLE_SAFETY_POLL_MS = 1000;
|
|
4645
|
+
const untilIdle = (target, timeoutMs) => {
|
|
4646
|
+
if (idleVerdict(target) === undefined)
|
|
4647
|
+
return waitForIdle(target, isBusy, timeoutMs, () => false, { rampMs: 0, confirm: 2 });
|
|
4648
|
+
return new Promise((resolve) => {
|
|
4649
|
+
let done = false;
|
|
4650
|
+
const finish = (r) => {
|
|
4651
|
+
if (done)
|
|
4652
|
+
return;
|
|
4653
|
+
done = true;
|
|
4654
|
+
unwatch.forEach((f) => f());
|
|
4655
|
+
clearInterval(tick);
|
|
4656
|
+
clearTimeout(timer);
|
|
4657
|
+
resolve(r);
|
|
4658
|
+
};
|
|
4659
|
+
// 会话中途退出 (注册表不认得了): pane 也不忙就是「那里什么都没在跑」, 与 isBusy 同一口径。
|
|
4660
|
+
const check = async () => {
|
|
4661
|
+
if (done)
|
|
4662
|
+
return;
|
|
4663
|
+
const v = idleVerdict(target);
|
|
4664
|
+
if (v ?? !(await isBusy(target)))
|
|
4665
|
+
finish({ idle: true });
|
|
4666
|
+
};
|
|
4667
|
+
const unwatch = activeBackends().map((b) => watchRegistry(b.homeDir, () => void check()));
|
|
4668
|
+
const tick = setInterval(() => void check(), IDLE_SAFETY_POLL_MS);
|
|
4669
|
+
const timer = setTimeout(() => finish({ idle: false, reason: "idle wait timed out" }), timeoutMs);
|
|
4670
|
+
void check();
|
|
4671
|
+
});
|
|
4672
|
+
};
|
|
4576
4673
|
const lastText = (target) => {
|
|
4577
4674
|
const p = jsonlOf(target);
|
|
4578
4675
|
return p ? lastAssistantText(p, 4000, warmerSigs) : "";
|
|
@@ -4581,6 +4678,15 @@ export const startMirror = (deps) => {
|
|
|
4581
4678
|
const p = jsonlOf(target);
|
|
4582
4679
|
return p ? lastReply(p, sinceMs, warmerSigs) : "";
|
|
4583
4680
|
};
|
|
4681
|
+
const replyToPeerSince = (target, fromName, sinceMs = 0) => {
|
|
4682
|
+
const p = jsonlOf(target);
|
|
4683
|
+
return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs) : undefined;
|
|
4684
|
+
};
|
|
4685
|
+
/** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
|
|
4686
|
+
const paneLiveOf = async (target) => {
|
|
4687
|
+
const a = byTarget.get(target);
|
|
4688
|
+
return a?.tmuxPane ? paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
4689
|
+
};
|
|
4584
4690
|
// ── 未收口气泡的引用判定 ────────────────────────────────────────────
|
|
4585
4691
|
// tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
|
|
4586
4692
|
// 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
|
|
@@ -5043,6 +5149,8 @@ export const startMirror = (deps) => {
|
|
|
5043
5149
|
chatRoster,
|
|
5044
5150
|
peekTurns,
|
|
5045
5151
|
isBusy,
|
|
5152
|
+
idleNow,
|
|
5153
|
+
untilIdle,
|
|
5046
5154
|
setModel: async (target, wanted, scope) => {
|
|
5047
5155
|
const a = byTarget.get(target);
|
|
5048
5156
|
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
@@ -5069,6 +5177,9 @@ export const startMirror = (deps) => {
|
|
|
5069
5177
|
}),
|
|
5070
5178
|
lastText,
|
|
5071
5179
|
lastReply: replySince,
|
|
5180
|
+
replyToPeer: replyToPeerSince,
|
|
5181
|
+
paneLive: paneLiveOf,
|
|
5182
|
+
restored,
|
|
5072
5183
|
isOpenBubbleQuote,
|
|
5073
5184
|
status: () => {
|
|
5074
5185
|
const list = Array.from(bySessionId.values()).map((a) => ({
|
|
@@ -5238,8 +5349,12 @@ export const startMirror = (deps) => {
|
|
|
5238
5349
|
if (!text.trim())
|
|
5239
5350
|
return { ok: false, reason: "empty text" };
|
|
5240
5351
|
// 出处改写要早于 inject: tail 可能在 promise resolve 之前就开出 turn。
|
|
5241
|
-
|
|
5242
|
-
|
|
5352
|
+
// 守护进程注入的一轮 (回执 / peer / 定时任务 / graph) 不是人在 CLI 手敲的: 可见性
|
|
5353
|
+
// 只由频道定 ("" 私聊才静默)。pendingFromCli 必须一并清 —— 它是粘性的, 不开轮的
|
|
5354
|
+
// CLI 行 (spawn 时 model-select 按下的 `/model`) 会把它一直挂到下一轮, 让紧跟着的
|
|
5355
|
+
// 注入轮被认成 CLI 发起, 公开频道里的回复因此被出处门吞掉。
|
|
5356
|
+
a.pendingFromCli = false;
|
|
5357
|
+
a.turnFromChat = true;
|
|
5243
5358
|
// 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
|
|
5244
5359
|
// 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
|
|
5245
5360
|
// 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
|
|
@@ -5262,9 +5377,6 @@ export const startMirror = (deps) => {
|
|
|
5262
5377
|
// 注入的那一行会被 recentInjects 当回显吞掉, 走不到 user_text —— 本轮的 query
|
|
5263
5378
|
// (rolepage 上「谁对它说了什么」那一半) 只能在这里记。
|
|
5264
5379
|
a.pendingBriefQuery = text;
|
|
5265
|
-
// 公开频道里说的话, 回复就该在群里看得见 —— 哪怕上一轮是人在 CLI 里敲的。
|
|
5266
|
-
if (opts?.channel)
|
|
5267
|
-
a.turnFromChat = true;
|
|
5268
5380
|
const sid = a.sessionId;
|
|
5269
5381
|
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
5270
5382
|
if (!paneAlive) {
|
|
@@ -5287,10 +5399,22 @@ export const startMirror = (deps) => {
|
|
|
5287
5399
|
// 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
|
|
5288
5400
|
a.muteUntilInject = false;
|
|
5289
5401
|
a.justSpawned = false;
|
|
5402
|
+
// 同 dispatch 的 /clear 与静默分叉跟随: self-handoff / 同伴 / 定时任务也走这里注入,
|
|
5403
|
+
// 漏了它 tail 就钉在旧 jsonl 上, 直到下一条人话经 dispatch 才把它拽回来。
|
|
5404
|
+
const clearing = isClearCommand(text);
|
|
5405
|
+
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5290
5406
|
const r = await inject({
|
|
5291
5407
|
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
5292
5408
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
|
|
5293
5409
|
});
|
|
5410
|
+
if (r.ok && a.tmuxPane) {
|
|
5411
|
+
if (preClearBaseline) {
|
|
5412
|
+
a.clearRebind = { baseline: preClearBaseline };
|
|
5413
|
+
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
5414
|
+
}
|
|
5415
|
+
else
|
|
5416
|
+
armSilentForkRebind(a, full);
|
|
5417
|
+
}
|
|
5294
5418
|
return r;
|
|
5295
5419
|
},
|
|
5296
5420
|
interruptPane: async (target, opts) => {
|