wezard 1.2.18 → 1.2.21
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 +40 -0
- package/dist/daemon/approval.js +79 -43
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-name.js +86 -0
- package/dist/daemon/chat-name.js.map +1 -0
- package/dist/daemon/inbound.js +143 -7
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +177 -48
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +162 -93
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +33 -7
- package/dist/daemon/peers.js.map +1 -1
- package/dist/mcp/server.js +60 -26
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/claude-config-path.js +12 -2
- package/dist/shared/claude-config-path.js.map +1 -1
- package/dist/shared/config.js +6 -0
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/session-label.js +51 -0
- package/dist/shared/session-label.js.map +1 -1
- package/launchd/com.wezard.daemon.plist.template +0 -2
- package/package.json +1 -1
|
@@ -21,9 +21,10 @@ import { isModalPane, parseModalOptions, pickModalAnswer } from "../shared/modal
|
|
|
21
21
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
|
|
22
22
|
import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
23
23
|
import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
24
|
-
import { labelFor, tagOfKey, baseOfKey, withTagHeader } from "../shared/session-label.js";
|
|
24
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader } from "../shared/session-label.js";
|
|
25
25
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
26
26
|
import { randomTip } from "./tips.js";
|
|
27
|
+
import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
27
28
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
28
29
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
29
30
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
@@ -2116,73 +2117,39 @@ export const startMirror = (deps) => {
|
|
|
2116
2117
|
clearTimeout(a.keepaliveQuiet);
|
|
2117
2118
|
a.keepaliveQuiet = undefined;
|
|
2118
2119
|
};
|
|
2119
|
-
// A
|
|
2120
|
-
// (
|
|
2121
|
-
// else means the model ignored the ping and resumed real work.
|
|
2122
|
-
const pongPrefix = (s) => "pong".startsWith(s.replace(/[^a-zA-Z]/g, "").toLowerCase());
|
|
2123
|
-
// Stop swallowing the in-flight ping turn: close its heartbeat detail turn,
|
|
2124
|
-
// lift the quiet window, and re-anchor the clocks to "real activity now" so the
|
|
2125
|
-
// resumed work resets the round budget. The caller then falls through to the
|
|
2126
|
-
// normal onItem path, letting this and the rest of the turn reach chat.
|
|
2127
|
-
const releaseKeepalive = (a) => {
|
|
2128
|
-
if (a.keepaliveTurnId) {
|
|
2129
|
-
recordTurnClose(a.keepaliveTurnId);
|
|
2130
|
-
a.keepaliveTurnId = undefined;
|
|
2131
|
-
}
|
|
2132
|
-
endKeepaliveQuiet(a);
|
|
2133
|
-
a.keepalivePongBuf = undefined;
|
|
2134
|
-
if (a.keepalive) {
|
|
2135
|
-
a.keepalive.round = 0;
|
|
2136
|
-
a.keepalive.pinging = false;
|
|
2137
|
-
a.keepalive.settledAt = 0;
|
|
2138
|
-
a.keepalive.lastRealMs = Date.now();
|
|
2139
|
-
}
|
|
2140
|
-
log.info({ target: a.target, sessionId: a.sessionId }, "keepalive: reply diverged from pong — un-swallowing resumed turn");
|
|
2141
|
-
};
|
|
2120
|
+
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
2121
|
+
// matter (model may add extra commentary beyond "pong", that's fine).
|
|
2142
2122
|
const onItem = (a, item) => {
|
|
2143
2123
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
2144
2124
|
if (a.muteUntilInject)
|
|
2145
2125
|
return;
|
|
2146
2126
|
// Keepalive ping turns are cache-warmers: swallow every item from the WeCom
|
|
2147
2127
|
// paths so the ping/pong never reaches chat. But record the REAL exchange
|
|
2148
|
-
// into its chat-detail turn — the actual assistant reply
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
// synthetic summary. Closed on the terminal signal (hard or soft turn_end).
|
|
2128
|
+
// into its chat-detail turn — the actual assistant reply, the tool calls if
|
|
2129
|
+
// any, and the usage (proof it was a cheap cache-read) — so the detail page
|
|
2130
|
+
// shows the genuine heartbeat, not a synthetic summary.
|
|
2152
2131
|
if (a.keepaliveQuiet) {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
const divergent = item.kind === "tool_use" || (item.kind === "text" && !pongPrefix(nextBuf));
|
|
2159
|
-
if (!divergent) {
|
|
2160
|
-
if (item.kind === "text")
|
|
2161
|
-
a.keepalivePongBuf = nextBuf;
|
|
2162
|
-
const id = a.keepaliveTurnId;
|
|
2163
|
-
if (id) {
|
|
2164
|
-
const now = Date.now();
|
|
2165
|
-
if (item.kind === "text") {
|
|
2166
|
-
recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2167
|
-
}
|
|
2168
|
-
else if (item.kind === "tool_result") {
|
|
2169
|
-
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2170
|
-
}
|
|
2171
|
-
else if (item.kind === "turn_usage") {
|
|
2172
|
-
recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2173
|
-
}
|
|
2174
|
-
else if (item.kind === "turn_end") {
|
|
2175
|
-
recordTurnClose(id);
|
|
2176
|
-
a.keepaliveTurnId = undefined;
|
|
2177
|
-
}
|
|
2132
|
+
const id = a.keepaliveTurnId;
|
|
2133
|
+
if (id) {
|
|
2134
|
+
const now = Date.now();
|
|
2135
|
+
if (item.kind === "text") {
|
|
2136
|
+
recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2178
2137
|
}
|
|
2179
|
-
if (item.kind === "
|
|
2180
|
-
|
|
2181
|
-
a.keepalivePongBuf = undefined;
|
|
2138
|
+
else if (item.kind === "tool_result") {
|
|
2139
|
+
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2182
2140
|
}
|
|
2183
|
-
|
|
2141
|
+
else if (item.kind === "turn_usage") {
|
|
2142
|
+
recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2143
|
+
}
|
|
2144
|
+
else if (item.kind === "turn_end") {
|
|
2145
|
+
recordTurnClose(id);
|
|
2146
|
+
a.keepaliveTurnId = undefined;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
if (item.kind === "turn_end") {
|
|
2150
|
+
endKeepaliveQuiet(a);
|
|
2184
2151
|
}
|
|
2185
|
-
|
|
2152
|
+
return;
|
|
2186
2153
|
}
|
|
2187
2154
|
// 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
|
|
2188
2155
|
// 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
|
|
@@ -3420,10 +3387,95 @@ export const startMirror = (deps) => {
|
|
|
3420
3387
|
// untagged default plus every `#tag`. Live attachments are the truth; the
|
|
3421
3388
|
// persisted store fills in cold bindings so a peer nobody has talked to since
|
|
3422
3389
|
// the last reload is still discoverable (and revivable) rather than invisible.
|
|
3390
|
+
const allTargets = () => {
|
|
3391
|
+
const keys = new Set([...byTarget.keys(), ...Object.keys(deps.store.all())]);
|
|
3392
|
+
return Array.from(keys).sort();
|
|
3393
|
+
};
|
|
3423
3394
|
const chatTargets = (target) => {
|
|
3424
3395
|
const base = basePrincipalOf(target);
|
|
3425
|
-
|
|
3426
|
-
|
|
3396
|
+
return allTargets().filter((k) => basePrincipalOf(k) === base);
|
|
3397
|
+
};
|
|
3398
|
+
// 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
|
|
3399
|
+
// 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
|
|
3400
|
+
const resolveChatRef = (ref) => {
|
|
3401
|
+
const base = chatBaseOf(cfg, ref);
|
|
3402
|
+
if (base)
|
|
3403
|
+
return { ok: true, base };
|
|
3404
|
+
const known = listChatNames(cfg).map((c) => c.name);
|
|
3405
|
+
return {
|
|
3406
|
+
ok: false,
|
|
3407
|
+
reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
|
|
3408
|
+
candidates: known,
|
|
3409
|
+
};
|
|
3410
|
+
};
|
|
3411
|
+
// peer 寻址。地址是两级的(见 chat-name.ts):
|
|
3412
|
+
// "" 本 chat 的 default —— 自身语义不变;
|
|
3413
|
+
// `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
|
|
3414
|
+
// 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
|
|
3415
|
+
// `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
|
|
3416
|
+
// `daily#` daily 的 default 会话;
|
|
3417
|
+
// `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
|
|
3418
|
+
// 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
|
|
3419
|
+
// 的全称地址"——后者不需要动别人的会话。
|
|
3420
|
+
const resolvePeerTag = (self, ref) => {
|
|
3421
|
+
const { chat, tag } = parsePeerRef(ref ?? "");
|
|
3422
|
+
const t = tag.trim();
|
|
3423
|
+
if (chat) {
|
|
3424
|
+
const c = resolveChatRef(chat);
|
|
3425
|
+
if (!c.ok)
|
|
3426
|
+
return c;
|
|
3427
|
+
const target = keyOf(c.base, t);
|
|
3428
|
+
if (allTargets().includes(target)) {
|
|
3429
|
+
return { ok: true, target, foreign: c.base !== basePrincipalOf(self) };
|
|
3430
|
+
}
|
|
3431
|
+
return {
|
|
3432
|
+
ok: false,
|
|
3433
|
+
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
3434
|
+
candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base),
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
3437
|
+
const local = keyOf(basePrincipalOf(self), t);
|
|
3438
|
+
if (!t)
|
|
3439
|
+
return { ok: true, target: local, foreign: false };
|
|
3440
|
+
const all = allTargets();
|
|
3441
|
+
if (all.includes(local))
|
|
3442
|
+
return { ok: true, target: local, foreign: false };
|
|
3443
|
+
const foreignMatches = all.filter((k) => tagOfKey(k) === t && basePrincipalOf(k) !== basePrincipalOf(self));
|
|
3444
|
+
if (foreignMatches.length === 1)
|
|
3445
|
+
return { ok: true, target: foreignMatches[0], foreign: true };
|
|
3446
|
+
if (foreignMatches.length === 0) {
|
|
3447
|
+
// 裸 token 正好是个 chat 名字 —— 用户/agent 想说的是"那个群",不是"那个 tag"。
|
|
3448
|
+
// 直接给出它的会话地址,比让人再查一次 list_chats 快。
|
|
3449
|
+
const asChat = chatBaseOf(cfg, t);
|
|
3450
|
+
if (asChat) {
|
|
3451
|
+
return {
|
|
3452
|
+
ok: false,
|
|
3453
|
+
reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
|
|
3454
|
+
candidates: allTargets().filter((k) => basePrincipalOf(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
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)` };
|
|
3458
|
+
}
|
|
3459
|
+
return {
|
|
3460
|
+
ok: false,
|
|
3461
|
+
reason: `tag '#${t}' exists in ${foreignMatches.length} chats — address it in full as 'chatName#${t}' (list_chats shows the names)`,
|
|
3462
|
+
candidates: foreignMatches.map((k) => peerAddress(cfg, self, k)),
|
|
3463
|
+
};
|
|
3464
|
+
};
|
|
3465
|
+
/** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
|
|
3466
|
+
* 它暂时只能靠全局唯一 tag 被找到的原因。 */
|
|
3467
|
+
const chatRoster = (self) => {
|
|
3468
|
+
const bases = new Set([
|
|
3469
|
+
...listChatNames(cfg).map((c) => c.base),
|
|
3470
|
+
...allTargets().map(basePrincipalOf),
|
|
3471
|
+
basePrincipalOf(self),
|
|
3472
|
+
]);
|
|
3473
|
+
return [...bases].filter(Boolean).sort().map((base) => ({
|
|
3474
|
+
base,
|
|
3475
|
+
name: chatNameOf(cfg, base),
|
|
3476
|
+
self: base === basePrincipalOf(self),
|
|
3477
|
+
targets: allTargets().filter((k) => basePrincipalOf(k) === base),
|
|
3478
|
+
}));
|
|
3427
3479
|
};
|
|
3428
3480
|
const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
|
|
3429
3481
|
const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
|
|
@@ -3436,38 +3488,52 @@ export const startMirror = (deps) => {
|
|
|
3436
3488
|
const p = jsonlOf(target);
|
|
3437
3489
|
return p ? lastAssistantText(p) : "";
|
|
3438
3490
|
};
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3491
|
+
// 一个 target 的完整画像。本 chat 的兄弟和外 chat 的 peer 走同一条,只有
|
|
3492
|
+
// `self` / `address` 因观察者而异 —— 地址本来就是相对调用方说的话。
|
|
3493
|
+
const peerInfoOf = async (t, self) => {
|
|
3494
|
+
const a = byTarget.get(t);
|
|
3495
|
+
const rec = deps.store.get(t);
|
|
3496
|
+
const jsonlPath = jsonlOf(t);
|
|
3497
|
+
const pane = paneOf(t);
|
|
3498
|
+
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
3499
|
+
const tag = tagOfTarget(t);
|
|
3500
|
+
let lastActivity = 0;
|
|
3501
|
+
try {
|
|
3502
|
+
if (jsonlPath)
|
|
3503
|
+
lastActivity = statSync(jsonlPath).mtimeMs;
|
|
3504
|
+
}
|
|
3505
|
+
catch { /* not written yet */ }
|
|
3506
|
+
return {
|
|
3507
|
+
target: t,
|
|
3508
|
+
tag,
|
|
3509
|
+
chat: chatNameOf(cfg, t),
|
|
3510
|
+
address: peerAddress(cfg, self, t),
|
|
3511
|
+
label: tag ? labelFor(tag) : "🧙",
|
|
3512
|
+
sessionId: a?.sessionId || rec?.sessionId || "",
|
|
3513
|
+
jsonlPath,
|
|
3514
|
+
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
3515
|
+
cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
|
|
3516
|
+
tmuxPane: pane,
|
|
3517
|
+
attached: !!a,
|
|
3518
|
+
paneAlive,
|
|
3519
|
+
busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
|
|
3520
|
+
lastActivity,
|
|
3521
|
+
summary: jsonlPath ? summarizeTail(jsonlPath) : "(未绑定会话)",
|
|
3522
|
+
self: t === self,
|
|
3523
|
+
};
|
|
3524
|
+
};
|
|
3525
|
+
const byRecent = (x, y) => y.lastActivity - x.lastActivity;
|
|
3526
|
+
const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
|
|
3527
|
+
// list_peers 的跨 chat 补充:其他 chat 里**当前调用方叫得动**的 session。两条
|
|
3528
|
+
// 入选路径 —— tag 全局唯一(裸 tag 就能命中),或者它所在的 chat 有名字(全称
|
|
3529
|
+
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
3530
|
+
// 只要给群起个名,不必回去改别人的 tag。
|
|
3531
|
+
const foreignPeers = async (self) => {
|
|
3532
|
+
const selfBase = basePrincipalOf(self);
|
|
3533
|
+
const foreign = allTargets().filter((k) => tagOfKey(k) && basePrincipalOf(k) !== selfBase);
|
|
3534
|
+
const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
|
|
3535
|
+
const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
|
|
3536
|
+
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
3471
3537
|
};
|
|
3472
3538
|
// Capture a few extra rows then compact away the TUI's blank padding, so
|
|
3473
3539
|
// `rows` counts lines the user actually cares about.
|
|
@@ -3533,7 +3599,6 @@ export const startMirror = (deps) => {
|
|
|
3533
3599
|
// buys a deterministic reply. keepaliveStamps matches every form, so none
|
|
3534
3600
|
// reads as real activity. (round is incremented below, after the inject lands.)
|
|
3535
3601
|
const text = stalled ? kc.resumePing : kc.ping;
|
|
3536
|
-
a.keepalivePongBuf = ""; // fresh reply accumulator for the swallow/release gate
|
|
3537
3602
|
// Suppress the pane→WeCom echo of the ping user line, then swallow the whole
|
|
3538
3603
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
3539
3604
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
@@ -3678,7 +3743,11 @@ export const startMirror = (deps) => {
|
|
|
3678
3743
|
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
3679
3744
|
return {
|
|
3680
3745
|
attach,
|
|
3746
|
+
chatTargets,
|
|
3681
3747
|
peers,
|
|
3748
|
+
foreignPeers,
|
|
3749
|
+
resolvePeerTag,
|
|
3750
|
+
chatRoster,
|
|
3682
3751
|
peekPane,
|
|
3683
3752
|
peekTurns,
|
|
3684
3753
|
isBusy,
|