wezard 2.0.3 → 2.0.5
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 +119 -291
- package/dist/daemon/cc-session.js +2 -0
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/inbound.js +25 -34
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +75 -21
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +122 -31
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/model-select.js +173 -61
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/peers.js +65 -8
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +16 -4
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/wizard.js +16 -4
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +21 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/detail-render.js +87 -265
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/role-view.js +9 -4
- package/dist/shared/role-view.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +44 -68
- package/web/chat.js +25 -21
|
@@ -25,7 +25,8 @@ import { isAutoWindowActive } from "./session-cache.js";
|
|
|
25
25
|
import { noticeSuffixFor } from "./notices.js";
|
|
26
26
|
import { dangerOf } from "./danger.js";
|
|
27
27
|
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
28
|
-
import {
|
|
28
|
+
import { selectModel } from "./model-select.js";
|
|
29
|
+
import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince } from "./cc-session.js";
|
|
29
30
|
import { wizardStore } from "./wizard.js";
|
|
30
31
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
31
32
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
@@ -33,7 +34,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagH
|
|
|
33
34
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
34
35
|
import { randomTip } from "./tips.js";
|
|
35
36
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
36
|
-
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
37
|
+
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, tailTurns, renderDialog } from "./peers.js";
|
|
37
38
|
// PATH augmentation: launchd / systemd start the daemon
|
|
38
39
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
39
40
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
@@ -294,7 +295,11 @@ const renderTaskNotification = (block) => {
|
|
|
294
295
|
const icon = status === "completed" ? "✅" : status === "failed" || status === "error" ? "❌" : "🏁";
|
|
295
296
|
return `${icon} ${summary || `后台任务 ${status || "完成"}`}`;
|
|
296
297
|
};
|
|
297
|
-
const cleanUserText = (
|
|
298
|
+
const cleanUserText = (wrapped) => {
|
|
299
|
+
// 多行注入落盘时被 CLI 包了一层 `<pasted_content>` (见 peers.unwrapPasted) —— 先剥,
|
|
300
|
+
// 否则回显去重对不上, 我们自己注入的那一行会被认成「人在 CLI 里敲的」, 整轮回复
|
|
301
|
+
// 因此被 chatOriginOnly 挡在群外 (公开的 send_peer / 定时任务的多行提示全中)。
|
|
302
|
+
const raw = unwrapPasted(wrapped);
|
|
298
303
|
const nameMatch = raw.match(SLASH_TAG_RE);
|
|
299
304
|
const slashCmd = nameMatch?.[1]?.trim() ?? "";
|
|
300
305
|
const argsMatch = raw.match(SLASH_ARGS_RE);
|
|
@@ -1326,7 +1331,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1326
1331
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
1327
1332
|
// pays the latency cost.
|
|
1328
1333
|
const PASTE_VERIFY_MS = freshSpawn ? 6000 : 2500;
|
|
1334
|
+
// 粘贴落定的等待**上限**, 不是固定 sleep: 框认得出来时按 SETTLE_POLL_MS 轮询
|
|
1335
|
+
// 「内容已落进输入框且不再变」, 一满足就回车 (见 pasteSettled)。
|
|
1329
1336
|
const POST_PASTE_SETTLE_MS = freshSpawn ? 1500 : 400;
|
|
1337
|
+
const SETTLE_POLL_MS = 50;
|
|
1330
1338
|
const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
|
|
1331
1339
|
const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
|
|
1332
1340
|
const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
|
|
@@ -1417,10 +1425,28 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1417
1425
|
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1418
1426
|
}
|
|
1419
1427
|
}
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
|
|
1423
|
-
|
|
1428
|
+
// 粘贴落定 = 框里是我们的内容, 且连续两次读到同一个样子 —— TUI 已把整段粘贴
|
|
1429
|
+
// (bracketed-paste end 在内) 消化完, 紧跟的回车不会被并进粘贴。实测冷启动的
|
|
1430
|
+
// pane 上粘贴 40–210ms 即落定, 此前这里固定睡 1500ms。到上限仍没等到就照旧
|
|
1431
|
+
// 回车 (与原先睡满等价), 回车被吞由下面的证人 + 补回车兜底。
|
|
1432
|
+
const pasteSettled = async (maxMs, prev, t0 = Date.now()) => {
|
|
1433
|
+
if (Date.now() - t0 >= maxMs)
|
|
1434
|
+
return false;
|
|
1435
|
+
const box = await readInputBox(target);
|
|
1436
|
+
const body = box.known && !boxIntact(box) ? box.body : undefined;
|
|
1437
|
+
if (body !== undefined && body === prev)
|
|
1438
|
+
return true;
|
|
1439
|
+
await sleep(SETTLE_POLL_MS);
|
|
1440
|
+
return pasteSettled(maxMs, body, t0);
|
|
1441
|
+
};
|
|
1442
|
+
// Bracketed-paste end + TUI catch-up. 框认不出来 / 粘贴没被看见时没有条件可查,
|
|
1443
|
+
// 维持固定等待 (fresh respawn: claude --resume is still loading the transcript)。
|
|
1444
|
+
if (!alreadyTyped) {
|
|
1445
|
+
if (pasteSeen && boxVerify)
|
|
1446
|
+
await pasteSettled(POST_PASTE_SETTLE_MS);
|
|
1447
|
+
else
|
|
1448
|
+
await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1449
|
+
}
|
|
1424
1450
|
const sendEnter = async () => {
|
|
1425
1451
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1426
1452
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
@@ -1539,6 +1565,9 @@ const queues = new Map();
|
|
|
1539
1565
|
* behind it forever. Well under brief's 350s bubble ceiling so the user still
|
|
1540
1566
|
* gets an error bubble rather than a silent stream timeout. */
|
|
1541
1567
|
const INJECT_JOB_TIMEOUT_MS = 90_000;
|
|
1568
|
+
/** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
|
|
1569
|
+
* 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
|
|
1570
|
+
const ORPHAN_GRACE_MS = 10 * 60_000;
|
|
1542
1571
|
/** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
|
|
1543
1572
|
* tmux read), but detaching the chain means the NEXT message doesn't inherit
|
|
1544
1573
|
* its deadlock. Pair with an injectGen bump so the zombie can't act when it
|
|
@@ -1566,7 +1595,9 @@ export const startMirror = (deps) => {
|
|
|
1566
1595
|
const INJECT_TTL_MS = 60_000;
|
|
1567
1596
|
const recentInjects = [];
|
|
1568
1597
|
const rememberInject = (text) => {
|
|
1569
|
-
|
|
1598
|
+
// 与 tail 侧同一个规范形 (cleanUserText): 注入时挂在尾巴上的 system-reminder
|
|
1599
|
+
// (信封 / 名册增量 / mention hint) 在那边是被剥掉之后才来比对的。
|
|
1600
|
+
const t = cleanUserText(text);
|
|
1570
1601
|
if (!t)
|
|
1571
1602
|
return;
|
|
1572
1603
|
recentInjects.push({ text: t, ts: Date.now() });
|
|
@@ -2802,6 +2833,7 @@ export const startMirror = (deps) => {
|
|
|
2802
2833
|
// slower than the 60s fail-safe. Reuses the detail turn the timer flow may
|
|
2803
2834
|
// have opened (fireKeepalive skips opening when this got there first).
|
|
2804
2835
|
const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
|
|
2836
|
+
const PONG_RE = /pong/i;
|
|
2805
2837
|
const beginKeepaliveByContent = (a, text) => {
|
|
2806
2838
|
a.keepaliveByContent = true;
|
|
2807
2839
|
if (a.keepaliveContentTimer)
|
|
@@ -2855,6 +2887,19 @@ export const startMirror = (deps) => {
|
|
|
2855
2887
|
// keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
|
|
2856
2888
|
if (item.kind === "skill_output" && item.agentDone)
|
|
2857
2889
|
clearBackgroundAgent(a, item.agentDone);
|
|
2890
|
+
// Reply-side belt: a "pong" arriving with NO swallow holding. Both triggers
|
|
2891
|
+
// above live in memory, and the tail re-attaches at EOF — a reload between
|
|
2892
|
+
// the ping and its reply forgets the ping was ever sent, and the pong opens
|
|
2893
|
+
// a chat turn of its own. So ask the transcript: if the last thing said to
|
|
2894
|
+
// the model is a keepalive ping, this text is its pong — swallow from here
|
|
2895
|
+
// to the turn_end, recorded as the same marked detail turn. Gated on the
|
|
2896
|
+
// transcript, not on the word alone: a real answer that mentions pong has a
|
|
2897
|
+
// real question in front of it.
|
|
2898
|
+
if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
|
|
2899
|
+
const ping = openKeepalivePing(a.jsonlPath, keepalivePingSigs);
|
|
2900
|
+
if (ping)
|
|
2901
|
+
beginKeepaliveByContent(a, ping);
|
|
2902
|
+
}
|
|
2858
2903
|
if (a.keepaliveQuiet || a.keepaliveByContent) {
|
|
2859
2904
|
const id = a.keepaliveTurnId;
|
|
2860
2905
|
if (id) {
|
|
@@ -4456,6 +4501,10 @@ export const startMirror = (deps) => {
|
|
|
4456
4501
|
const p = jsonlOf(target);
|
|
4457
4502
|
return p ? lastAssistantText(p) : "";
|
|
4458
4503
|
};
|
|
4504
|
+
const replySince = (target, sinceMs = 0) => {
|
|
4505
|
+
const p = jsonlOf(target);
|
|
4506
|
+
return p ? lastReply(p, sinceMs) : "";
|
|
4507
|
+
};
|
|
4459
4508
|
// ── 未收口气泡的引用判定 ────────────────────────────────────────────
|
|
4460
4509
|
// tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
|
|
4461
4510
|
// 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
|
|
@@ -4917,7 +4966,21 @@ export const startMirror = (deps) => {
|
|
|
4917
4966
|
peekPane,
|
|
4918
4967
|
peekTurns,
|
|
4919
4968
|
isBusy,
|
|
4969
|
+
setModel: async (target, wanted, scope) => {
|
|
4970
|
+
const a = byTarget.get(target);
|
|
4971
|
+
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
4972
|
+
return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
|
|
4973
|
+
const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
|
|
4974
|
+
if (r.ok && r.applied) {
|
|
4975
|
+
a.model = r.applied;
|
|
4976
|
+
const rec = deps.store.get(target);
|
|
4977
|
+
if (rec)
|
|
4978
|
+
deps.store.set(target, { ...rec, model: r.applied });
|
|
4979
|
+
}
|
|
4980
|
+
return r;
|
|
4981
|
+
},
|
|
4920
4982
|
lastText,
|
|
4983
|
+
lastReply: replySince,
|
|
4921
4984
|
isOpenBubbleQuote,
|
|
4922
4985
|
status: () => {
|
|
4923
4986
|
const list = Array.from(bySessionId.values()).map((a) => ({
|
|
@@ -5092,7 +5155,10 @@ export const startMirror = (deps) => {
|
|
|
5092
5155
|
// 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
|
|
5093
5156
|
// 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
|
|
5094
5157
|
// 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
|
|
5095
|
-
|
|
5158
|
+
// 信封 (谁说的、私聊还是公开、回执怎么交) 同理挂在尾巴上; slash 命令按行解析,
|
|
5159
|
+
// 多挂一段就不再是命令, 跳过。`pendingBriefQuery` 记的仍是不带信封的原话。
|
|
5160
|
+
const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
|
|
5161
|
+
const full = text + envelope + noticeSuffixFor(target, text);
|
|
5096
5162
|
// Pre-record so the tail's user-line emission is suppressed by the
|
|
5097
5163
|
// recentInjects dedupe (otherwise the user sees their own demo prompt
|
|
5098
5164
|
// echoed back as a quoted bubble).
|
|
@@ -5236,18 +5302,55 @@ export const startMirror = (deps) => {
|
|
|
5236
5302
|
// 一次 list-panes 同时回答「活着吗」和「有人盯着吗」—— 跑了几个月的机器上
|
|
5237
5303
|
// allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
|
|
5238
5304
|
// WORLD_PROBE 取舍)。
|
|
5239
|
-
const
|
|
5305
|
+
const orphans = [];
|
|
5306
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{window_activity}"]);
|
|
5240
5307
|
if (snap.code !== 0)
|
|
5241
|
-
return { reaped, skipped };
|
|
5242
|
-
const
|
|
5243
|
-
|
|
5308
|
+
return { reaped, orphans, skipped };
|
|
5309
|
+
const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
|
|
5310
|
+
const watchedBy = new Map(rows.map(([id = "", w = ""]) => {
|
|
5244
5311
|
const [wActive, sAttached] = w.split(":");
|
|
5245
5312
|
return [id, wActive === "1" && sAttached !== "0"];
|
|
5246
5313
|
}));
|
|
5247
|
-
|
|
5314
|
+
// 收一个 pane; 返回没收成的原因, 收掉了返回 undefined。
|
|
5315
|
+
const putDown = async (pane) => {
|
|
5316
|
+
// 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
|
|
5317
|
+
if (watchedBy.get(pane))
|
|
5318
|
+
return "watched";
|
|
5319
|
+
if (paneIsBusy(await capturePaneTail(pane, 12)))
|
|
5320
|
+
return "busy";
|
|
5321
|
+
// 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
|
|
5322
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5323
|
+
await sleep(250);
|
|
5324
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5325
|
+
return r.code === 0 ? undefined : `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
|
|
5326
|
+
};
|
|
5327
|
+
const targets = allTargets();
|
|
5328
|
+
// 孤儿: 住在 daemon 自己的 tmux session 里、却没有任何绑定指着它的 pane —— 绑定
|
|
5329
|
+
// 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
|
|
5330
|
+
// 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
|
|
5331
|
+
// 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
|
|
5332
|
+
// 用 window_activity 留一段宽限。只收会话 pane: 注册表认得里面住着活的 AI 会话
|
|
5333
|
+
// 才算; 普通 pane (人自己开的 shell / 编辑器, 会话已退回 shell 的, 没有注册表的
|
|
5334
|
+
// 后端) 一律跳过 —— 认不出就不动。
|
|
5335
|
+
const bound = new Set(targets.map(paneOf).filter(Boolean));
|
|
5336
|
+
const hosting = new Set(activeBackends().flatMap((b) => [...sessionPanes(b.homeDir)]));
|
|
5337
|
+
for (const [pane = "", , session, activity] of rows) {
|
|
5338
|
+
if (session !== cfg.wrc.tmuxPrefix || bound.has(pane) || !hosting.has(pane))
|
|
5339
|
+
continue;
|
|
5340
|
+
if (now - Number(activity) * 1000 < ORPHAN_GRACE_MS)
|
|
5341
|
+
continue;
|
|
5342
|
+
const why = await putDown(pane);
|
|
5343
|
+
if (why) {
|
|
5344
|
+
skipped[pane] = why;
|
|
5345
|
+
continue;
|
|
5346
|
+
}
|
|
5347
|
+
orphans.push(pane);
|
|
5348
|
+
log.info({ pane, idleHours: Math.round((now - Number(activity) * 1000) / 3.6e6) }, "mirror pane cap — orphan pane killed (no binding)");
|
|
5349
|
+
}
|
|
5350
|
+
const live = targets.filter((t) => watchedBy.has(paneOf(t)));
|
|
5248
5351
|
let over = live.length - max;
|
|
5249
5352
|
if (over <= 0)
|
|
5250
|
-
return { reaped, skipped };
|
|
5353
|
+
return { reaped, orphans, skipped };
|
|
5251
5354
|
// 超了才 stat: 最久没动的先收。还没写过 transcript 的是刚起的 pane, 按"此刻"算。
|
|
5252
5355
|
const mtimeOf = (target) => {
|
|
5253
5356
|
try {
|
|
@@ -5269,21 +5372,9 @@ export const startMirror = (deps) => {
|
|
|
5269
5372
|
continue;
|
|
5270
5373
|
}
|
|
5271
5374
|
const pane = paneOf(target);
|
|
5272
|
-
|
|
5273
|
-
if (
|
|
5274
|
-
skipped[target] =
|
|
5275
|
-
continue;
|
|
5276
|
-
}
|
|
5277
|
-
if (paneIsBusy(await capturePaneTail(pane, 12))) {
|
|
5278
|
-
skipped[target] = "busy";
|
|
5279
|
-
continue;
|
|
5280
|
-
}
|
|
5281
|
-
// 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
|
|
5282
|
-
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5283
|
-
await sleep(250);
|
|
5284
|
-
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5285
|
-
if (r.code !== 0) {
|
|
5286
|
-
skipped[target] = `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
|
|
5375
|
+
const why = await putDown(pane);
|
|
5376
|
+
if (why) {
|
|
5377
|
+
skipped[target] = why;
|
|
5287
5378
|
continue;
|
|
5288
5379
|
}
|
|
5289
5380
|
if (a)
|
|
@@ -5293,7 +5384,7 @@ export const startMirror = (deps) => {
|
|
|
5293
5384
|
over -= 1;
|
|
5294
5385
|
log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6), max }, "mirror pane cap — pane killed, binding kept");
|
|
5295
5386
|
}
|
|
5296
|
-
return { reaped, skipped };
|
|
5387
|
+
return { reaped, orphans, skipped };
|
|
5297
5388
|
},
|
|
5298
5389
|
submitPane: async (target) => {
|
|
5299
5390
|
const a = byTarget.get(target);
|