wezard 1.3.18 → 1.3.19
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/config.example.jsonc +3 -0
- package/dist/daemon/mirror-bridge.js +49 -93
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +17 -71
- package/dist/daemon/peers.js.map +1 -1
- package/dist/shared/config.js +7 -15
- package/dist/shared/config.js.map +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -54,7 +54,7 @@ Three processes, all coordinating via 127.0.0.1:17890 and `~/.wezard/`:
|
|
|
54
54
|
- `approval.ts` builds the button card, parks an HTTP request via `pending.ts`, and resolves it on `event.template_card_event`. Three click outcomes: `allow` / `allow_window` (auto-allow N min for that session) / `deny`. `session-cache.ts` short-circuits repeats.
|
|
55
55
|
- `inbound.ts` routes `message.text` / `message.image` / `message.mixed` → either headless `cc-bridge.ts` or `mirror-bridge.ts`. Gates on `allowFrom`; one-shot exception is the bootstrap claim (see below).
|
|
56
56
|
- `cc-bridge.ts` (mode=headless) spawns `claude -p --output-format stream-json --verbose`, parses `assistant`/`stream_event` lines, and pumps text via `client.replyStream`. Per-principal queue serializes turns; session id is captured from the `system/init` line and persisted to `~/.wezard/sessions.json` for `--resume`.
|
|
57
|
-
- `mirror-bridge.ts` (mode=mirror) tails `<projectsDir>/<encoded(cwd)>/<sid>.jsonl` and pushes new `assistant` content to a chat. Inbound injection is either `claude --resume <sid> -p` (writes a turn into the same jsonl) or `tmux paste-buffer` against a live TTY (only path that makes WeCom messages visible in the user's CLI window). `recentInjects` ring buffer suppresses the WeCom→CLI echo when tailing.
|
|
57
|
+
- `mirror-bridge.ts` (mode=mirror) tails `<projectsDir>/<encoded(cwd)>/<sid>.jsonl` and pushes new `assistant` content to a chat — but only for turns the chat itself started: a turn opened by a human's CLI keystroke is recorded to the turn store (chat-detail page) and kept out of the chat (`wrc.mirror.chatOriginOnly`, brief mode only); approval/askq cards and `[mirror]` notices bypass that gate. Inbound injection is either `claude --resume <sid> -p` (writes a turn into the same jsonl) or `tmux paste-buffer` against a live TTY (only path that makes WeCom messages visible in the user's CLI window). `recentInjects` ring buffer suppresses the WeCom→CLI echo when tailing.
|
|
58
58
|
- `claim.ts` is a single-shot bootstrap: an exact-match magic phrase from any sender bypasses `allowFrom`, writes that principal as `defaultChat`, and appends to `allowFrom`. This is the **only** legitimate ingress for first-time setup.
|
|
59
59
|
- `peers.ts` is the read side of multi-agent awareness: sibling sessions of one chat are exactly the target keys sharing a base principal (`baseOfKey`), i.e. the untagged default plus every `#tag`. Pure parsing only — bounded transcript-tail reads (`tailTurns` / `lastAssistantText` / `summarizeTail`, shared with `session-scan.ts`) and pane classification (`paneIsBusy`). **Busy detection matches the TUI spinner footer** (`… (12m 31s · …)` on current versions, `esc to interrupt` on older ones) in the last 8 non-blank rows — transcript mtime is useless here, it goes quiet during long tool calls.
|
|
60
60
|
- `graph.ts` is the loop-graph runner: `nodes` = tagged sessions (own cli/model/cwd), `steps` = an ordered pipeline walked `rounds` times, each step injecting a prompt, waiting for that agent to go idle, then feeding its reply forward as `{{last}}` / `{{<tag>}}`. Stops early on the `until` sentinel. All effects go through injected deps backed by `mirror-bridge`, so a step is exactly what a human typing `#tag …` would do. Runs are **in-memory only** — a reload cancels them (panes survive).
|
package/config.example.jsonc
CHANGED
|
@@ -57,6 +57,9 @@
|
|
|
57
57
|
// 中间 tool_use / tool_result / thinking 只写进详情页 (可折叠, Claude 网页风格)。
|
|
58
58
|
// 授权卡照常发群。
|
|
59
59
|
"brief": false,
|
|
60
|
+
// 出处门: CLI 手敲的那一轮只进终端 + chat 详情页, 不下发气泡。WeCom 发起的
|
|
61
|
+
// 轮次、审批卡、[mirror] 提示不受影响。只在 brief=true 下生效。
|
|
62
|
+
"chatOriginOnly": true,
|
|
60
63
|
// 斜杠命令回执精简: /clear、/new 只回第一行 ack ("cleared"/"created"),
|
|
61
64
|
// 不再附 📂 项目信息与 💡 tip。默认 false = 完整回执。
|
|
62
65
|
"slashAckFirstLine": false
|
|
@@ -28,7 +28,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } f
|
|
|
28
28
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
29
29
|
import { randomTip } from "./tips.js";
|
|
30
30
|
import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
31
|
-
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled,
|
|
31
|
+
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
32
32
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
33
33
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
34
34
|
const NODE_BIN_DIR = dirname(process.execPath);
|
|
@@ -2027,6 +2027,21 @@ export const startMirror = (deps) => {
|
|
|
2027
2027
|
const tag = tagOfKey(target);
|
|
2028
2028
|
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
2029
2029
|
};
|
|
2030
|
+
// ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
|
|
2031
|
+
// 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
|
|
2032
|
+
// store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
|
|
2033
|
+
// 在看。群里保留的是需要在群里发生的事: WeCom 发起的轮次、审批/提问卡、
|
|
2034
|
+
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2035
|
+
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2036
|
+
const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
|
|
2037
|
+
// 详情页入口一次性告知。CLI 轮全程静默后, 一个从未收到过气泡的会话在群里将没有
|
|
2038
|
+
// 任何链接可点 —— 每个 attachment 发一条 (且仅一条) 带链接的提示解决这个死角。
|
|
2039
|
+
const noticeCliSilent = (a, turnId) => {
|
|
2040
|
+
if (a.cliSilentNoticed)
|
|
2041
|
+
return;
|
|
2042
|
+
a.cliSilentNoticed = true;
|
|
2043
|
+
sendRaw(a, `${briefDetailLink(turnId, a.target)} CLI 侧对话不再下发到群里,点标签看实时详情。`);
|
|
2044
|
+
};
|
|
2030
2045
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2031
2046
|
// raw=true skips withSessionTag (used when content already contains the linked tag header).
|
|
2032
2047
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2089,6 +2104,8 @@ export const startMirror = (deps) => {
|
|
|
2089
2104
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
|
|
2090
2105
|
const turnId = newTurnId();
|
|
2091
2106
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2107
|
+
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2108
|
+
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2092
2109
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
|
|
2093
2110
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2094
2111
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
@@ -2130,7 +2147,12 @@ export const startMirror = (deps) => {
|
|
|
2130
2147
|
const turnId = newTurnId();
|
|
2131
2148
|
const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
|
|
2132
2149
|
a.pendingBriefQuery = undefined;
|
|
2133
|
-
|
|
2150
|
+
// 出处继承: 有真人 CLI 输入行开头 → CLI 轮; 否则 (收口后的补写、peer/graph 注入
|
|
2151
|
+
// 开出的轮) 沿用上一轮 —— 那些续写属于同一场对话, 不该改变可见性。
|
|
2152
|
+
const fromCli = a.pendingFromCli === true;
|
|
2153
|
+
a.pendingFromCli = false;
|
|
2154
|
+
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2155
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: query || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
|
|
2134
2156
|
a.briefTurnId = turnId;
|
|
2135
2157
|
a.briefBubble = undefined;
|
|
2136
2158
|
a.briefIsSlash = false;
|
|
@@ -2140,8 +2162,12 @@ export const startMirror = (deps) => {
|
|
|
2140
2162
|
clearCot(a);
|
|
2141
2163
|
// 链接暂存, 不再单独发 — 让首条 standalone body 取走拼到前缀, 一条消息解决。
|
|
2142
2164
|
// turn 始终会通过 concludeBriefTurn / closeBriefTurn 收口, header 不会泄漏。
|
|
2143
|
-
|
|
2144
|
-
|
|
2165
|
+
// 静默轮不留 header: 它没有任何 standalone 可以搭车, 留着只是个悬空的链接。
|
|
2166
|
+
if (turnSilent(a))
|
|
2167
|
+
noticeCliSilent(a, turnId);
|
|
2168
|
+
else
|
|
2169
|
+
a.pendingBriefHeader = briefDetailLink(turnId, a.target);
|
|
2170
|
+
log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
|
|
2145
2171
|
};
|
|
2146
2172
|
// 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
|
|
2147
2173
|
// 就挂在气泡里, 无需再推一次。气泡仍然不关, 等正文到来后以 `链接 正文` 覆盖收口。
|
|
@@ -2190,6 +2216,11 @@ export const startMirror = (deps) => {
|
|
|
2190
2216
|
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2191
2217
|
a.pendingBriefHeader = undefined;
|
|
2192
2218
|
}
|
|
2219
|
+
else if (turnSilent(a)) {
|
|
2220
|
+
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
2221
|
+
a.pendingBriefHeader = undefined;
|
|
2222
|
+
log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
|
|
2223
|
+
}
|
|
2193
2224
|
else {
|
|
2194
2225
|
const header = a.pendingBriefHeader;
|
|
2195
2226
|
a.pendingBriefHeader = undefined;
|
|
@@ -2476,6 +2507,10 @@ export const startMirror = (deps) => {
|
|
|
2476
2507
|
void finishBriefBubble(a, item.body);
|
|
2477
2508
|
a.pendingBriefHeader = undefined;
|
|
2478
2509
|
}
|
|
2510
|
+
else if (turnSilent(a)) {
|
|
2511
|
+
// CLI 里手敲的 /clear、/model 之类, 回执给敲的人看就够了 —— 只进详情页。
|
|
2512
|
+
a.pendingBriefHeader = undefined;
|
|
2513
|
+
}
|
|
2479
2514
|
else {
|
|
2480
2515
|
// CLI-driven turn: 把 ensureBriefTurn 暂存的详情链接拼到 body 前缀, 避免
|
|
2481
2516
|
// "只发了一条 link 渲染成空文本" 的前置消息。
|
|
@@ -2670,6 +2705,11 @@ export const startMirror = (deps) => {
|
|
|
2670
2705
|
// 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
|
|
2671
2706
|
voidTurnAgents(a);
|
|
2672
2707
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1;
|
|
2708
|
+
// 这一行是人在 CLI 里敲的 —— 它既是下一轮的 query, 也是下一轮的出处。
|
|
2709
|
+
// quiet (includeUser=false, 默认) 同样记: includeUser 只决定气泡渲不渲染,
|
|
2710
|
+
// 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
|
|
2711
|
+
a.pendingBriefQuery = item.body;
|
|
2712
|
+
a.pendingFromCli = true;
|
|
2673
2713
|
}
|
|
2674
2714
|
// 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。放在
|
|
2675
2715
|
// keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
|
|
@@ -2877,13 +2917,9 @@ export const startMirror = (deps) => {
|
|
|
2877
2917
|
}
|
|
2878
2918
|
// Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
|
|
2879
2919
|
// 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
|
|
2880
|
-
//
|
|
2881
|
-
if (cfg.wrc.mirror.brief && !a.briefTurnId)
|
|
2882
|
-
|
|
2883
|
-
a.pendingBriefQuery = item.body;
|
|
2884
|
-
else if (BRIEF_TURN_OPENERS.has(item.kind))
|
|
2885
|
-
ensureBriefTurn(a);
|
|
2886
|
-
}
|
|
2920
|
+
// user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
|
|
2921
|
+
if (cfg.wrc.mirror.brief && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
|
|
2922
|
+
ensureBriefTurn(a);
|
|
2887
2923
|
// Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
|
|
2888
2924
|
// 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
|
|
2889
2925
|
// 输出的 item 类型。
|
|
@@ -4352,10 +4388,8 @@ export const startMirror = (deps) => {
|
|
|
4352
4388
|
// Stall recovery, decided by RULE only (no model self-judgment): the last
|
|
4353
4389
|
// transcript turn is a synthetic API-error/limit line, or the idle pane
|
|
4354
4390
|
// still shows an error banner ⇒ a turn died mid-work. Send the resume
|
|
4355
|
-
// instruction instead of the plain warmer.
|
|
4356
|
-
|
|
4357
|
-
// limitResumeTick owns that recovery, anchored on the reset moment.
|
|
4358
|
-
const stalled = kc.resumeOnStall && !a.limitResume && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
|
|
4391
|
+
// instruction instead of the plain warmer.
|
|
4392
|
+
const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
|
|
4359
4393
|
k.pinging = true;
|
|
4360
4394
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
4361
4395
|
await fireKeepalive(a, stalled);
|
|
@@ -4369,83 +4403,6 @@ export const startMirror = (deps) => {
|
|
|
4369
4403
|
}
|
|
4370
4404
|
};
|
|
4371
4405
|
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
4372
|
-
// ── Rate-limit auto-resume ────────────────────────────────────────────
|
|
4373
|
-
// Detection is poll-derived from the transcript tail every tick (limitResetAt:
|
|
4374
|
-
// "last message turn IS a 429 line"), so there is nothing to persist — a
|
|
4375
|
-
// daemon reload, a human retry in the TTY, or our own inject all converge
|
|
4376
|
-
// naturally: any newer turn makes detection stop firing and drops the state.
|
|
4377
|
-
const fmtClock = (ms) => {
|
|
4378
|
-
const d = new Date(ms);
|
|
4379
|
-
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
4380
|
-
};
|
|
4381
|
-
let limitResumeTicking = false;
|
|
4382
|
-
const limitResumeTick = async () => {
|
|
4383
|
-
const lr = cfg.wrc.mirror.limitResume;
|
|
4384
|
-
if (!lr.enabled || limitResumeTicking)
|
|
4385
|
-
return;
|
|
4386
|
-
limitResumeTicking = true;
|
|
4387
|
-
try {
|
|
4388
|
-
const now = Date.now();
|
|
4389
|
-
for (const a of byTarget.values()) {
|
|
4390
|
-
const resetsAt = limitResetAt(a.jsonlPath, now);
|
|
4391
|
-
if (!resetsAt) {
|
|
4392
|
-
a.limitResume = undefined;
|
|
4393
|
-
continue;
|
|
4394
|
-
}
|
|
4395
|
-
if (a.migrationWatcher)
|
|
4396
|
-
continue; // session rotating — judge after it settles
|
|
4397
|
-
if (a.keepaliveOff)
|
|
4398
|
-
continue; // /stop = deliberately quieted; honor it
|
|
4399
|
-
if (a.limitResume?.resetsAt !== resetsAt)
|
|
4400
|
-
a.limitResume = { resetsAt, notified: false, retried: false };
|
|
4401
|
-
const st = a.limitResume;
|
|
4402
|
-
const dueAt = resetsAt + lr.delaySec * 1000;
|
|
4403
|
-
if (!st.notified) {
|
|
4404
|
-
st.notified = true;
|
|
4405
|
-
log.info({ target: a.target, resetsAt, dueAt }, "limit-resume: parked, retry scheduled");
|
|
4406
|
-
sendStandalone(a, `[mirror] ⏳ 已触限额 (reset ${fmtClock(resetsAt)}) — ${fmtClock(dueAt)} 自动注入 \`${lr.text}\` 续跑`);
|
|
4407
|
-
}
|
|
4408
|
-
if (st.retried || now < dueAt)
|
|
4409
|
-
continue;
|
|
4410
|
-
if (a.liveStream && !a.liveStream.closed)
|
|
4411
|
-
continue; // mid typewriter — don't inject
|
|
4412
|
-
if (a.tmuxPane) {
|
|
4413
|
-
// Pane mode: a dead pane means the human closed shop — auto-respawning
|
|
4414
|
-
// a TUI at 3am would be a surprise, not a service. Busy means someone
|
|
4415
|
-
// (human, or another turn) is already driving; detection clears itself
|
|
4416
|
-
// once their turn lands. Spawn-mode has no pane and no such hazards —
|
|
4417
|
-
// inject's `claude --resume -p` fallback IS its normal operation.
|
|
4418
|
-
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
4419
|
-
continue;
|
|
4420
|
-
if (paneIsBusy(await capturePaneTail(a.tmuxPane, 16)))
|
|
4421
|
-
continue;
|
|
4422
|
-
}
|
|
4423
|
-
st.retried = true;
|
|
4424
|
-
// Unlike keepalive there is no swallow: the resumed work is real work —
|
|
4425
|
-
// the tail mirrors its output to chat normally. rememberInject only
|
|
4426
|
-
// suppresses the echo of the injected user line itself.
|
|
4427
|
-
rememberInject(lr.text);
|
|
4428
|
-
const r = await inject({
|
|
4429
|
-
text: lr.text, images: [], cfg,
|
|
4430
|
-
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "limit-resume" }),
|
|
4431
|
-
sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
|
|
4432
|
-
});
|
|
4433
|
-
if (!r.ok) {
|
|
4434
|
-
st.retried = false; // roll back so the next tick retries the inject
|
|
4435
|
-
log.warn({ target: a.target, reason: r.reason }, "limit-resume: inject failed");
|
|
4436
|
-
continue;
|
|
4437
|
-
}
|
|
4438
|
-
log.info({ target: a.target, resetsAt }, "limit-resume: resume injected");
|
|
4439
|
-
}
|
|
4440
|
-
}
|
|
4441
|
-
catch (e) {
|
|
4442
|
-
log.warn({ err: e.message }, "limit-resume tick failed");
|
|
4443
|
-
}
|
|
4444
|
-
finally {
|
|
4445
|
-
limitResumeTicking = false;
|
|
4446
|
-
}
|
|
4447
|
-
};
|
|
4448
|
-
const limitResumeTimer = setInterval(() => void limitResumeTick(), 30_000);
|
|
4449
4406
|
// ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
|
|
4450
4407
|
// CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
|
|
4451
4408
|
// 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
|
|
@@ -4961,7 +4918,6 @@ export const startMirror = (deps) => {
|
|
|
4961
4918
|
shutdown: () => {
|
|
4962
4919
|
clearInterval(paneDriftTimer);
|
|
4963
4920
|
clearInterval(keepaliveTimer);
|
|
4964
|
-
clearInterval(limitResumeTimer);
|
|
4965
4921
|
clearInterval(pickerTimer);
|
|
4966
4922
|
for (const a of bySessionId.values()) {
|
|
4967
4923
|
if (a.outbound?.kind === "deferred")
|