wezard 0.3.3

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.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/CLAUDE.md +83 -0
  4. package/LICENSE +21 -0
  5. package/README.md +410 -0
  6. package/cli/weclaude.sh +236 -0
  7. package/commands/audit.md +7 -0
  8. package/commands/wrc.md +7 -0
  9. package/config.example.jsonc +102 -0
  10. package/dist/cli/audit.js +156 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/init.js +311 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/cli/sync.js +175 -0
  15. package/dist/cli/sync.js.map +1 -0
  16. package/dist/daemon/approval.js +1301 -0
  17. package/dist/daemon/approval.js.map +1 -0
  18. package/dist/daemon/ask.js +101 -0
  19. package/dist/daemon/ask.js.map +1 -0
  20. package/dist/daemon/audit.js +168 -0
  21. package/dist/daemon/audit.js.map +1 -0
  22. package/dist/daemon/cc-bridge.js +204 -0
  23. package/dist/daemon/cc-bridge.js.map +1 -0
  24. package/dist/daemon/cfg-sync.js +240 -0
  25. package/dist/daemon/cfg-sync.js.map +1 -0
  26. package/dist/daemon/claim.js +95 -0
  27. package/dist/daemon/claim.js.map +1 -0
  28. package/dist/daemon/detail.js +153 -0
  29. package/dist/daemon/detail.js.map +1 -0
  30. package/dist/daemon/graph.js +176 -0
  31. package/dist/daemon/graph.js.map +1 -0
  32. package/dist/daemon/http.js +83 -0
  33. package/dist/daemon/http.js.map +1 -0
  34. package/dist/daemon/inbound.js +919 -0
  35. package/dist/daemon/inbound.js.map +1 -0
  36. package/dist/daemon/index.js +569 -0
  37. package/dist/daemon/index.js.map +1 -0
  38. package/dist/daemon/last-response.js +59 -0
  39. package/dist/daemon/last-response.js.map +1 -0
  40. package/dist/daemon/mirror-bridge.js +3512 -0
  41. package/dist/daemon/mirror-bridge.js.map +1 -0
  42. package/dist/daemon/mirror-store.js +39 -0
  43. package/dist/daemon/mirror-store.js.map +1 -0
  44. package/dist/daemon/outbound.js +42 -0
  45. package/dist/daemon/outbound.js.map +1 -0
  46. package/dist/daemon/peers.js +146 -0
  47. package/dist/daemon/peers.js.map +1 -0
  48. package/dist/daemon/pending.js +90 -0
  49. package/dist/daemon/pending.js.map +1 -0
  50. package/dist/daemon/quota.js +164 -0
  51. package/dist/daemon/quota.js.map +1 -0
  52. package/dist/daemon/redact.js +27 -0
  53. package/dist/daemon/redact.js.map +1 -0
  54. package/dist/daemon/session-cache.js +149 -0
  55. package/dist/daemon/session-cache.js.map +1 -0
  56. package/dist/daemon/session-scan.js +273 -0
  57. package/dist/daemon/session-scan.js.map +1 -0
  58. package/dist/daemon/sessions.js +35 -0
  59. package/dist/daemon/sessions.js.map +1 -0
  60. package/dist/daemon/spawn-tmux.js +213 -0
  61. package/dist/daemon/spawn-tmux.js.map +1 -0
  62. package/dist/daemon/tips.js +25 -0
  63. package/dist/daemon/tips.js.map +1 -0
  64. package/dist/daemon/topics.js +197 -0
  65. package/dist/daemon/topics.js.map +1 -0
  66. package/dist/daemon/usage.js +358 -0
  67. package/dist/daemon/usage.js.map +1 -0
  68. package/dist/daemon/wedoc.js +210 -0
  69. package/dist/daemon/wedoc.js.map +1 -0
  70. package/dist/daemon/ws.js +67 -0
  71. package/dist/daemon/ws.js.map +1 -0
  72. package/dist/mcp/server.js +385 -0
  73. package/dist/mcp/server.js.map +1 -0
  74. package/dist/shared/ansi.js +134 -0
  75. package/dist/shared/ansi.js.map +1 -0
  76. package/dist/shared/chat-http.js +195 -0
  77. package/dist/shared/chat-http.js.map +1 -0
  78. package/dist/shared/chat-render.js +53 -0
  79. package/dist/shared/chat-render.js.map +1 -0
  80. package/dist/shared/chat-view.js +116 -0
  81. package/dist/shared/chat-view.js.map +1 -0
  82. package/dist/shared/cli-backends.js +304 -0
  83. package/dist/shared/cli-backends.js.map +1 -0
  84. package/dist/shared/config-writer.js +39 -0
  85. package/dist/shared/config-writer.js.map +1 -0
  86. package/dist/shared/config.js +278 -0
  87. package/dist/shared/config.js.map +1 -0
  88. package/dist/shared/detail-render.js +836 -0
  89. package/dist/shared/detail-render.js.map +1 -0
  90. package/dist/shared/detail-store.js +205 -0
  91. package/dist/shared/detail-store.js.map +1 -0
  92. package/dist/shared/highlight.js +154 -0
  93. package/dist/shared/highlight.js.map +1 -0
  94. package/dist/shared/lan-ip.js +35 -0
  95. package/dist/shared/lan-ip.js.map +1 -0
  96. package/dist/shared/log.js +18 -0
  97. package/dist/shared/log.js.map +1 -0
  98. package/dist/shared/md-chunk.js +99 -0
  99. package/dist/shared/md-chunk.js.map +1 -0
  100. package/dist/shared/paths.js +12 -0
  101. package/dist/shared/paths.js.map +1 -0
  102. package/dist/shared/session-label.js +67 -0
  103. package/dist/shared/session-label.js.map +1 -0
  104. package/dist/shared/web-assets.js +51 -0
  105. package/dist/shared/web-assets.js.map +1 -0
  106. package/dist/svr/index.js +246 -0
  107. package/dist/svr/index.js.map +1 -0
  108. package/hooks/hooks.json +16 -0
  109. package/hooks/pre-tool-use.sh +167 -0
  110. package/launchd/com.weclaude.daemon.plist.template +45 -0
  111. package/package.json +97 -0
  112. package/scripts/install.sh +73 -0
  113. package/scripts/postinstall.sh +12 -0
  114. package/scripts/uninstall.sh +44 -0
  115. package/systemd/weclaude.service.template +17 -0
  116. package/web/chat.css +171 -0
  117. package/web/chat.js +367 -0
@@ -0,0 +1,3512 @@
1
+ // Mirror-mode bridge.
2
+ //
3
+ // Strategy: a running interactive `claude` exposes no local IPC. Its only
4
+ // shared surface is the append-only transcript at
5
+ // ~/.claude/projects/<encoded(cwd)>/<session-id>.jsonl
6
+ // So:
7
+ // • Inbound (WeCom → claude): spawn `claude --resume <sid> -p <text>` —
8
+ // writes a new user/assistant turn into the SAME jsonl. Serialized per
9
+ // session to avoid concurrent writers stomping each other.
10
+ // • Outbound (claude → WeCom): tail that jsonl from the current EOF; every
11
+ // new `assistant` line gets pushed to a configured WeCom chat.
12
+ //
13
+ // Caveat: the user shouldn't be hammering the same session in their local TTY
14
+ // while a `--resume` injection is in flight; Claude Code locks aren't strict.
15
+ import { spawn } from "node:child_process";
16
+ import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { expandHome, sanitizeId } from "../shared/paths.js";
19
+ import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
20
+ import { spawnTmuxClaude } from "./spawn-tmux.js";
21
+ import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
22
+ import { labelFor, tagOfKey, baseOfKey, withTagHeader } from "../shared/session-label.js";
23
+ import { splitMarkdown } from "../shared/md-chunk.js";
24
+ import { randomTip } from "./tips.js";
25
+ import { stripAnsi, compactPane, paneIsBusy, summarizeTail, lastAssistantText } from "./peers.js";
26
+ // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
27
+ // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
28
+ const NODE_BIN_DIR = dirname(process.execPath);
29
+ const augmentedPath = (orig) => {
30
+ const extras = [
31
+ NODE_BIN_DIR,
32
+ "/opt/homebrew/bin",
33
+ "/usr/local/bin",
34
+ `${process.env.HOME ?? ""}/.local/bin`,
35
+ ].filter(Boolean);
36
+ const seen = new Set();
37
+ return [orig ?? "", ...extras]
38
+ .flatMap((p) => p.split(":"))
39
+ .filter((p) => {
40
+ if (!p || seen.has(p))
41
+ return false;
42
+ seen.add(p);
43
+ return true;
44
+ })
45
+ .join(":");
46
+ };
47
+ // Backend resolution is per-transcript, not global: `backendForPath` recovers
48
+ // which CLI wrote a jsonl from its projects root, so claude / claude-internal /
49
+ // codebuddy sessions can be mirrored side by side. `projectDirFor` re-encodes a
50
+ // cwd under that same backend's dialect (Claude: leading `-`, CodeBuddy: none)
51
+ // so pane-drift comparisons stay apples-to-apples.
52
+ // Pull the bound session's actual project cwd from its transcript head. Each
53
+ // jsonl line carries a `cwd` field; the encoded directory name is lossy (both
54
+ // `/` and `.` collapse to `-`) so it can't be reversed — reading the file is
55
+ // the only faithful path. Used as the middle tier in attach()'s cwd resolution
56
+ // so /wrc-attached sessions reflect their real project in /pwd, /clear, /new,
57
+ // instead of falling back to the global cfg.wrc.cwd.
58
+ const readCwdFromJsonl = (path) => {
59
+ try {
60
+ if (!existsSync(path))
61
+ return "";
62
+ const size = statSync(path).size;
63
+ if (size === 0)
64
+ return "";
65
+ const fd = openSync(path, "r");
66
+ const cap = Math.min(size, 64 * 1024);
67
+ const buf = Buffer.alloc(cap);
68
+ readSync(fd, buf, 0, cap, 0);
69
+ closeSync(fd);
70
+ for (const line of buf.toString("utf8").split("\n")) {
71
+ if (!line.trim())
72
+ continue;
73
+ try {
74
+ const j = JSON.parse(line);
75
+ if (typeof j.cwd === "string" && j.cwd.trim())
76
+ return j.cwd.trim();
77
+ }
78
+ catch { /* partial / non-JSON line — skip */ }
79
+ }
80
+ }
81
+ catch { /* unreadable — fall through */ }
82
+ return "";
83
+ };
84
+ // Every *.jsonl under `cwd`'s project dir, ranked newest-mtime first. A cwd can
85
+ // hold transcripts from more than one CLI (same project opened in claude and in
86
+ // codebuddy), so the default is the union across all active backends — that is
87
+ // what lets a fresh session be discovered regardless of which binary wrote it.
88
+ //
89
+ // `only` narrows to a single backend. Every path that RE-BINDS an existing
90
+ // attachment must pass it: healing a claude chat onto the codebuddy transcript
91
+ // that merely happens to be newer in the same directory would hand the chat to
92
+ // a session it can neither resume (`claude --resume` wouldn't find the sid) nor
93
+ // parse (wrong jsonl dialect). Only genuine discovery searches the union.
94
+ const rankedJsonlsForCwd = (cwd, only) => projectDirsFor(expandHome(cwd))
95
+ .filter(({ backend }) => !only || backend.name === only.name)
96
+ .flatMap(({ dir }) => {
97
+ let names;
98
+ try {
99
+ names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ return names.flatMap((n) => {
105
+ const p = join(dir, n);
106
+ try {
107
+ return [{ sessionId: n.replace(/\.jsonl$/, ""), jsonlPath: p, mtime: statSync(p).mtimeMs }];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ });
113
+ })
114
+ .sort((a, b) => b.mtime - a.mtime);
115
+ // Newest-mtime *.jsonl in the project dir(s) for `cwd`. A `/clear` (or a native
116
+ // `/new`) rotation always leaves a fresh jsonl here, so this is how the mirror
117
+ // re-finds the live session after a rotation it didn't itself record — used by
118
+ // resolveSession's auto-pick and by restoreFromStore's heal path.
119
+ const latestJsonlForCwd = (cwd, only) => {
120
+ const top = rankedJsonlsForCwd(cwd, only)[0];
121
+ return top ? { sessionId: top.sessionId, jsonlPath: top.jsonlPath } : undefined;
122
+ };
123
+ // Locate a transcript by sessionId across every sibling project dir of every
124
+ // active backend. Claude Code's EnterWorktree/ExitWorktree relocate the SAME-sid
125
+ // jsonl between <cwd> and <cwd>/.claude/worktrees/<name> — each cwd encodes to
126
+ // its own project dir, so the path moves but the sid doesn't. It's a rename
127
+ // (byte prefix preserved), so a tail can follow it with a continuous offset.
128
+ // Newest-mtime wins if a stale sibling lingers.
129
+ const findJsonlBySid = (sid, only) => {
130
+ const sidFile = `${sid}.jsonl`;
131
+ return (only ? [only] : activeBackends())
132
+ .flatMap((b) => {
133
+ const root = expandHome(b.projectsDir);
134
+ try {
135
+ return readdirSync(root).map((d) => join(root, d, sidFile));
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ })
141
+ .flatMap((p) => { try {
142
+ return [{ p, m: statSync(p).mtimeMs }];
143
+ }
144
+ catch {
145
+ return [];
146
+ } })
147
+ .sort((a, b) => b.m - a.m)[0]?.p;
148
+ };
149
+ // Backend-agnostic line adapter for the raw-jsonl predicates below. They were
150
+ // written against Claude's schema (type:"user", isMeta, string content) —
151
+ // codebuddy persists message/function_call records instead, so route every
152
+ // parsed line through the owning backend's adapter first (identity for claude).
153
+ const normalizeForPath = (path, raw) => backendForPath(path).normalizeTranscriptLine(raw);
154
+ // Local-command noise on user lines: Claude marks the caveat / command-stdout
155
+ // records isMeta; codebuddy persists them as PLAIN user messages (no isMeta
156
+ // field at all). Predicates looking for the first REAL user line must skip
157
+ // both forms explicitly.
158
+ const isLocalCommandNoise = (content) => typeof content === "string" &&
159
+ (content.includes('data-role="command-caveat"') || content.includes("<local-command-stdout>"));
160
+ // True if `path` holds at least one real (non-meta, non-sidechain) user line —
161
+ // i.e. it's a live session, not an empty just-touched jsonl. Bounded head read.
162
+ const jsonlHasUserLine = (path) => {
163
+ try {
164
+ const size = statSync(path).size;
165
+ const cap = Math.min(size, 256 * 1024);
166
+ const fd = openSync(path, "r");
167
+ const buf = Buffer.alloc(cap);
168
+ readSync(fd, buf, 0, cap, 0);
169
+ closeSync(fd);
170
+ for (const line of buf.toString("utf8").split("\n")) {
171
+ if (!line.trim())
172
+ continue;
173
+ try {
174
+ const j = normalizeForPath(path, JSON.parse(line));
175
+ if (j?.type === "user" && !j.isMeta && !j.isSidechain)
176
+ return true;
177
+ }
178
+ catch { /* partial line */ }
179
+ }
180
+ }
181
+ catch { /* unreadable */ }
182
+ return false;
183
+ };
184
+ // Search a jsonl's tail for a just-injected message fingerprint; return the
185
+ // byte offset of the START of the line containing it, or undefined. Used to
186
+ // rebind onto a same-dir fork that swallowed our inject (see armSilentForkRebind)
187
+ // — matching the exact text is what makes cross-session rebind safe when many
188
+ // sessions share one project dir. Bounded tail read (the inject is near EOF).
189
+ const findInjectOffset = (path, fp) => {
190
+ try {
191
+ const size = statSync(path).size;
192
+ const readLen = Math.min(size, 256 * 1024);
193
+ const start = size - readLen;
194
+ const fd = openSync(path, "r");
195
+ const buf = Buffer.alloc(readLen);
196
+ try {
197
+ readSync(fd, buf, 0, readLen, start);
198
+ }
199
+ finally {
200
+ closeSync(fd);
201
+ }
202
+ const chunk = buf.toString("utf8");
203
+ const idx = chunk.lastIndexOf(fp);
204
+ if (idx === -1)
205
+ return undefined;
206
+ const lineStart = chunk.lastIndexOf("\n", idx) + 1; // -1 → 0 (chunk head)
207
+ return start + Buffer.byteLength(chunk.slice(0, lineStart), "utf8");
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ };
213
+ // Newest-mtime *.jsonl WITH user content in cwd's project dir(s), across all
214
+ // active backends. The content gate skips an empty just-touched file so we bind
215
+ // the real session. Reliable for worktree dirs (one session lives there);
216
+ // restore + the drift follower only consult it on cross-dir moves, where that
217
+ // assumption holds.
218
+ const liveSessionForCwd = (cwd, only) => {
219
+ for (const c of rankedJsonlsForCwd(cwd, only)) {
220
+ if (jsonlHasUserLine(c.jsonlPath))
221
+ return { sessionId: c.sessionId, jsonlPath: c.jsonlPath };
222
+ }
223
+ return undefined;
224
+ };
225
+ const resolveSession = (cfg, log) => {
226
+ const cwd = expandHome(cfg.wrc.cwd);
227
+ const dirs = projectDirsFor(cwd).map(({ dir }) => dir).filter((d) => existsSync(d));
228
+ if (dirs.length === 0) {
229
+ log.error({ probed: projectDirsFor(cwd).map(({ dir }) => dir) }, "mirror: project dir not found (no CLI session ever ran in cwd?)");
230
+ return undefined;
231
+ }
232
+ const pinned = cfg.wrc.mirror.sessionId.trim();
233
+ if (pinned) {
234
+ const p = dirs.map((d) => join(d, `${pinned}.jsonl`)).find((x) => existsSync(x));
235
+ if (!p) {
236
+ log.error({ pinned, dirs }, "mirror: pinned sessionId jsonl missing");
237
+ return undefined;
238
+ }
239
+ return { sessionId: pinned, jsonlPath: p };
240
+ }
241
+ const top = latestJsonlForCwd(cwd);
242
+ if (!top) {
243
+ log.error({ dirs }, "mirror: no .jsonl in project dir");
244
+ return undefined;
245
+ }
246
+ return top;
247
+ };
248
+ // Target keys are `user:xxx[#tag]` / `chat:xxx[#tag]`. Strip the principal
249
+ // prefix AND the `#tag` suffix so WeCom's sendMessage receives just the bare
250
+ // chatid/userid. Tag survives only inside the daemon's byTarget/store keys.
251
+ const stripPrincipalPrefix = (s) => {
252
+ const i = s.indexOf(":");
253
+ const rest = i >= 0 ? s.slice(i + 1) : s;
254
+ const h = rest.indexOf("#");
255
+ return h >= 0 ? rest.slice(0, h) : rest;
256
+ };
257
+ // Extract the `#tag` suffix from a target key, "" if untagged.
258
+ const tagOfTarget = tagOfKey;
259
+ // Drop the `#tag` suffix — collapses tagged session keys to the chat-scoped
260
+ // base principal. Shared with the peer/graph layer via session-label.
261
+ const basePrincipalOf = baseOfKey;
262
+ // Prefix outbound content with `<emoji> #tag` header (blank line separator)
263
+ // when the target carries a `#tag` suffix. Untagged targets pass through
264
+ // unchanged — default session keeps its plain-bubble UX.
265
+ const withSessionTag = withTagHeader;
266
+ // Claude Code wraps slash-command invocations into the user message as
267
+ // <command-message>name</command-message>
268
+ // <command-name>/name</command-name>
269
+ // <command-args>...</command-args>
270
+ // plus assorted <local-command-stdout>, <local-command-caveat>,
271
+ // <system-reminder> blocks. Rendering those raw to WeCom is pure noise.
272
+ //
273
+ // Strategy: extract /cmd + args into a single styled line; strip the rest.
274
+ // Returns "" when the message is purely meta — caller filters.
275
+ // Opening tag tolerates attributes: codebuddy emits e.g.
276
+ // `<system-reminder data-role="command-caveat">` (verified 100+ on disk) —
277
+ // a bare `<tag>` match lets the whole caveat leak into the WeCom bubble.
278
+ const SLASH_TAG_RE = /<command-name>([\s\S]*?)<\/command-name>/;
279
+ const SLASH_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/;
280
+ const META_TAG_RE = /<(command-message|command-name|command-args|local-command-stdout|local-command-caveat|system-reminder|task-notification)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
281
+ const TASK_NOTIF_RE = /<task-notification>([\s\S]*?)<\/task-notification>/;
282
+ // Claude Code's `/goal` installs a session-scoped Stop hook and injects this
283
+ // marker (type:"user", plain string content) telling the model to self-drive
284
+ // toward the condition. Crucially the model then NEVER emits a terminal
285
+ // stop_reason — every assistant line stays stop_reason:"tool_use" until the goal
286
+ // auto-clears — so `turn_end` (and thus brief-mode's closeBriefTurn / any
287
+ // per-turn flush) never fires, and the entire run goes silent on WeCom. Detect
288
+ // the marker on a stable English substring so the mirror can enter a
289
+ // progress-streaming mode for the goal's duration.
290
+ const GOAL_START_RE = /session-scoped Stop hook is now active with condition:\s*"?([^"\n]*)"?/;
291
+ // Pure: goal marker → goal_start item. The marker line ships as `type:"user",
292
+ // isMeta:true, content:string` (verified against real /goal transcripts), so
293
+ // detection MUST run before renderLine's isMeta gate — behind it the signal is
294
+ // dropped and the whole goal adaptation never engages.
295
+ const goalStartOf = (c) => {
296
+ if (typeof c !== "string")
297
+ return undefined;
298
+ const m = c.match(GOAL_START_RE);
299
+ return m ? { kind: "goal_start", condition: (m[1] ?? "").trim() } : undefined;
300
+ };
301
+ // Background task completion notification (Claude Code emits these into the
302
+ // user channel when a backgrounded Bash/Agent task finishes). Render the
303
+ // summary + status as a single styled line; drop the noisy tool-use-id /
304
+ // output-file fields.
305
+ const renderTaskNotification = (block) => {
306
+ const summary = block.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim() ?? "";
307
+ const status = block.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim() ?? "";
308
+ const icon = status === "completed" ? "✅" : status === "failed" || status === "error" ? "❌" : "🏁";
309
+ return `${icon} ${summary || `后台任务 ${status || "完成"}`}`;
310
+ };
311
+ const cleanUserText = (raw) => {
312
+ const nameMatch = raw.match(SLASH_TAG_RE);
313
+ const slashCmd = nameMatch?.[1]?.trim() ?? "";
314
+ const argsMatch = raw.match(SLASH_ARGS_RE);
315
+ const slashArgs = argsMatch?.[1]?.trim() ?? "";
316
+ const stripped = raw.replace(META_TAG_RE, "").trim();
317
+ if (slashCmd) {
318
+ const head = `\`${slashCmd}${slashArgs ? ` ${slashArgs}` : ""}\``;
319
+ return stripped ? `${head}\n${stripped}` : head;
320
+ }
321
+ return stripped;
322
+ };
323
+ const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
324
+ const renderToolInput = (input) => {
325
+ try {
326
+ const json = JSON.stringify(input ?? {}, null, 0);
327
+ return truncate(json, 600);
328
+ }
329
+ catch {
330
+ return "{}";
331
+ }
332
+ };
333
+ const extractToolResultText = (block) => {
334
+ const c = block.content;
335
+ if (typeof c === "string")
336
+ return c;
337
+ if (!Array.isArray(c))
338
+ return "";
339
+ return c
340
+ .map((b) => (typeof b?.text === "string" ? b.text : b?.tool_name ? `→ ${b.tool_name}` : ""))
341
+ .filter(Boolean)
342
+ .join("\n");
343
+ };
344
+ // detail 页用的存储上限 — 远大于聊天气泡的 toolResultMaxChars(默认 400),
345
+ // 确保点开"详情"能看到工具调用的完整 result, 不再受气泡截断的影响。
346
+ const DETAIL_RESULT_MAX = 64 * 1024;
347
+ const oneLineSummary = (s, max = 40) => {
348
+ const flat = s.replace(/\s+/g, " ").trim();
349
+ return truncate(flat, max);
350
+ };
351
+ // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
352
+ // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
353
+ // `<noreply@x>` etc. silently swallows the rest of the line plus the closing
354
+ // backtick, eating subsequent items. A literal backtick inside `compact` also
355
+ // closes the surrounding inline-code prematurely; `[`/`]` break the
356
+ // enclosing `[text](url)` link by re-anchoring the text span.
357
+ // Replace with full-width / similar glyphs: visually close, harmless to the
358
+ // renderer. Applied to the user-controlled part only — surrounding markdown
359
+ // structure (the wrapping ``…``, `[…](…)` and `> ↩ ` prefix) stays literal.
360
+ const safeForMarkdown = (s) => s
361
+ .replace(/`/g, "ʼ")
362
+ .replace(/</g, "<")
363
+ .replace(/>/g, ">")
364
+ .replace(/\[/g, "[")
365
+ .replace(/\]/g, "]")
366
+ // \( \) is LaTeX inline-math in WeCom desktop renderer → contents render
367
+ // italic and the link span breaks. Keep the backslash for fidelity, swap
368
+ // to full-width parens so the math tokenizer no longer recognizes it.
369
+ // (\[ \] is already neutralized above by the [/] replacement.)
370
+ .replace(/\\\(/g, "\\(")
371
+ .replace(/\\\)/g, "\\)");
372
+ const renderToolInputCompact = (input, max) => {
373
+ // Heuristic: prefer command/file_path/pattern-like keys for the inline summary.
374
+ if (input && typeof input === "object") {
375
+ const o = input;
376
+ const pick = o.command ?? o.file_path ?? o.path ?? o.pattern ?? o.url ?? o.query ?? o.prompt;
377
+ if (typeof pick === "string")
378
+ return oneLineSummary(pick, max);
379
+ }
380
+ return oneLineSummary(renderToolInput(input), max);
381
+ };
382
+ // 渲染一组同名 tool_use:
383
+ // • 单次 → `🔧 [Name compact](url)` (与原行为一致)
384
+ // • 多次 → 聚合为一个 markdown 块:
385
+ // 🔧 Name × N
386
+ // [• compact1](url1)
387
+ // [• compact2](url2)
388
+ // 多次的每条仍是独立 link, 各自指向自己的 detail URL。
389
+ const renderToolUseGroupBody = (calls, deps) => {
390
+ const renderOne = (c) => ({
391
+ compact: safeForMarkdown(renderToolInputCompact(c.input, deps.toolUseInlineMaxChars)),
392
+ url: deps.detailUrlFor(c.toolUseId),
393
+ });
394
+ if (calls.length === 1) {
395
+ const c = calls[0];
396
+ const { compact, url } = renderOne(c);
397
+ return url ? `🔧 [${c.name} ${compact}](${url})` : `🔧 ${c.name} ${compact}`;
398
+ }
399
+ const header = `🔧 ${calls[0].name} × ${calls.length}`;
400
+ const lines = calls.map((c) => {
401
+ const { compact, url } = renderOne(c);
402
+ return url ? `[• ${compact}](${url})` : `• ${compact}`;
403
+ });
404
+ return [header, ...lines].join("\n");
405
+ };
406
+ // Render one transcript line into tagged items. Caller decides batching.
407
+ const renderLine = (raw, deps) => {
408
+ let line;
409
+ try {
410
+ line = JSON.parse(raw);
411
+ }
412
+ catch {
413
+ return [];
414
+ }
415
+ // Phase 3: normalize backend-specific jsonl (codebuddy splits tool_use /
416
+ // tool_result into independent top-level records; Claude is identity). Done
417
+ // AFTER JSON.parse so the adapter works on structured data, BEFORE the
418
+ // isMeta/isSidechain gates so backend-mapped fields flow through.
419
+ if (deps.normalizeLine) {
420
+ const normalized = deps.normalizeLine(line);
421
+ if (!normalized)
422
+ return [];
423
+ line = normalized;
424
+ }
425
+ if (line.isSidechain)
426
+ return [];
427
+ if (line.isMeta) {
428
+ // isMeta 行整体丢弃, 唯一例外是 /goal 的激活 marker (isMeta:true 的 user
429
+ // 行) — 它是 goal 模式的进入信号, 吞掉它 = 整个 goal run 在 WeCom 静默。
430
+ const goal = line.type === "user" ? goalStartOf(line.message?.content) : undefined;
431
+ return goal ? [goal] : [];
432
+ }
433
+ const out = [];
434
+ if (line.type === "user") {
435
+ const c = line.message?.content;
436
+ if (typeof c === "string") {
437
+ // Skill/system feedback (e.g. `/model`'s "Set model to …", background
438
+ // task completions) is emitted REGARDLESS of includeUser — it's system
439
+ // output, not the user's own chatter, so the includeUser=false default
440
+ // (which suppresses echoing user lines) must not swallow it.
441
+ // Match against raw content BEFORE cleanUserText strips the tag.
442
+ const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
443
+ if (stdoutMatch && stdoutMatch[1]) {
444
+ const skillOutput = stdoutMatch[1].replace(/\[[0-9;]*m/g, "").trim();
445
+ if (skillOutput) {
446
+ out.push({ kind: "skill_output", body: `⚙️ ${skillOutput}` });
447
+ }
448
+ return out;
449
+ }
450
+ // Background task completion — strip the raw tag soup and render
451
+ // a single styled line.
452
+ const taskMatch = c.match(TASK_NOTIF_RE);
453
+ if (taskMatch && taskMatch[1]) {
454
+ out.push({ kind: "skill_output", body: renderTaskNotification(taskMatch[1]) });
455
+ return out;
456
+ }
457
+ // /goal activation — non-isMeta 形态兜底 (marker 格式漂移防御), emit
458
+ // REGARDLESS of includeUser; isMeta 形态已在上方 isMeta 门截获。
459
+ const goal = goalStartOf(c);
460
+ if (goal)
461
+ return [goal];
462
+ if (!deps.includeUser)
463
+ return [];
464
+ const text = cleanUserText(c);
465
+ if (!text)
466
+ return []; // pure slash-command meta / stdout — drop
467
+ if (deps.isOwnInject(text))
468
+ return []; // dedupe WeCom→CLI echo
469
+ const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
470
+ out.push({ kind: "user_text", body: quoted });
471
+ }
472
+ else if (Array.isArray(c)) {
473
+ for (const b of c) {
474
+ if (b?.type !== "tool_result")
475
+ continue;
476
+ const raw = extractToolResultText(b);
477
+ if (!raw)
478
+ continue;
479
+ const toolUseId = b.tool_use_id ?? "";
480
+ const full = truncate(raw, DETAIL_RESULT_MAX);
481
+ // 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
482
+ // includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
483
+ // turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
484
+ // 气泡推送的 gate 挪到非-brief 消费端 (onItem), 见下方 includeToolResults 判断。
485
+ if (toolUseId)
486
+ recordToolResult(toolUseId, full);
487
+ const compact = safeForMarkdown(oneLineSummary(full, 40));
488
+ const url = deps.detailUrlFor(toolUseId);
489
+ out.push({
490
+ kind: "tool_result",
491
+ toolUseId,
492
+ full,
493
+ body: url ? `[↩ ${compact}](${url})` : `↩ ${compact}`,
494
+ });
495
+ }
496
+ }
497
+ return out;
498
+ }
499
+ if (line.type === "assistant") {
500
+ const blocks = line.message?.content;
501
+ if (!Array.isArray(blocks))
502
+ return [];
503
+ // Mark text items emitted from a terminal-stop_reason line as `final`.
504
+ // Per Anthropic protocol, an `end_turn` line contains only text blocks
505
+ // (tool_use → stop_reason="tool_use"),
506
+ // so these texts ARE the agent's final answer for the turn. Downstream uses
507
+ // `final` to decide bubble splits — mid-turn text appends; only final text
508
+ // after tools peels out into its own standalone (preview = real answer).
509
+ // 只认 end_turn。另两个终态在 CC 落盘语义里都不是"这一轮说完了":
510
+ // • stop_sequence —— 实测 152 次里 ~150 次是 CC 合成的错误/限额行
511
+ // ("API Error: …" / "You've hit your session limit" / "No response
512
+ // requested."), 其中可重试的那种 (stalled mid-stream / ECONNRESET) CC 会
513
+ // 自动续跑同一轮, 在它上面收口 = turn 中途断掉。真正终止的那些后面必跟
514
+ // system/turn_duration, 由下方权威信号兜住, 不会漏收。
515
+ // • max_tokens —— 语义是"消息被截断", CC 继续同一轮; 全量 transcript 0 命中。
516
+ const sr = line.message?.stop_reason;
517
+ const isFinal = sr === "end_turn";
518
+ // final 是三态: true=终句, false=已知的中途输出, undefined=后端说不清 (软收口)。
519
+ // 软后端不能填 false —— 那会让每句叙述都触发 earlyLinkBubble, 把 loading 气泡
520
+ // 提前收成详情链接, 一句话答完的 turn 也要拆成两条消息。
521
+ const textFinal = isFinal ? true : line.softTurnEnd ? undefined : false;
522
+ let pending = [];
523
+ const flushPending = () => {
524
+ if (pending.length === 0)
525
+ return;
526
+ const calls = pending;
527
+ pending = [];
528
+ // 每个 tool_use 都要单独 recordTool, 这样点击-查看-详情可以按 id 命中。
529
+ for (const c of calls) {
530
+ if (c.toolUseId) {
531
+ recordTool({
532
+ id: c.toolUseId,
533
+ toolName: c.name,
534
+ toolInput: c.input,
535
+ sessionId: deps.sessionId,
536
+ target: deps.target,
537
+ });
538
+ }
539
+ }
540
+ out.push({
541
+ kind: "tool_use",
542
+ calls,
543
+ body: renderToolUseGroupBody(calls, deps),
544
+ });
545
+ };
546
+ for (const b of blocks) {
547
+ if (b?.type === "text" && typeof b.text === "string") {
548
+ flushPending();
549
+ const t = b.text.trim();
550
+ if (t && !deps.isOwnAssistantSend?.(t))
551
+ out.push({ kind: "text", body: t, final: textFinal });
552
+ }
553
+ else if (b?.type === "tool_use" && deps.includeTools) {
554
+ const name = b.name ?? "tool";
555
+ const toolUseId = b.id ?? "";
556
+ // 同名扩展当前 group; 不同名先 flush 再起新组。
557
+ if (pending.length > 0 && pending[0].name !== name)
558
+ flushPending();
559
+ pending.push({ toolUseId, name, input: b.input });
560
+ }
561
+ // thinking blocks intentionally skipped
562
+ }
563
+ flushPending();
564
+ // Emit per-line usage snapshot BEFORE turn_end so brief store gets the last
565
+ // increment before the turn closes. Non-brief onItem drops it silently.
566
+ const u = line.message?.usage;
567
+ if (u) {
568
+ const model = typeof line.message?.model === "string" ? line.message.model : undefined;
569
+ const messageId = typeof line.message?.id === "string" ? line.message.id : undefined;
570
+ const rawIn = u.input_tokens ?? 0;
571
+ const cr = u.cache_read_input_tokens ?? 0;
572
+ const cw = u.cache_creation_input_tokens ?? 0;
573
+ // CodeBuddy (claude-internal) 网关把 input_tokens 报成 cr+cw+fresh 的总和,
574
+ // 而 Anthropic 官方 input_tokens 只含 fresh (与 cache_creation/cache_read disjoint)。
575
+ // 按模型命名风格判定源头: CodeBuddy 是 "claude-4.7-opus" (点号版本),
576
+ // Anthropic 官方是 "claude-opus-4-7" (纯连字符)。只对 CodeBuddy 风格反推,
577
+ // 普通 Anthropic-native 会话保持原样, 不影响其 usage 计算。
578
+ const isGatewayTotalized = typeof model === "string" && /\d\.\d/.test(model);
579
+ const input = isGatewayTotalized && rawIn >= cr + cw ? rawIn - cr - cw : rawIn;
580
+ out.push({
581
+ kind: "turn_usage",
582
+ model,
583
+ messageId,
584
+ usage: {
585
+ input,
586
+ output: u.output_tokens ?? 0,
587
+ cacheRead: cr,
588
+ cacheWrite: cw,
589
+ serviceTier: u.service_tier,
590
+ calls: 1,
591
+ },
592
+ });
593
+ }
594
+ // Terminal stop_reason → emit turn_end so onItem closes the live bubble.
595
+ // `tool_use` is intentionally excluded — more turns will follow once the
596
+ // tool result lands; finalizing now would split a single logical reply.
597
+ //
598
+ // 但 stop_reason 是 **消息级** 字段, 而 CC 把同一个 message.id 拆成多行落盘
599
+ // (thinking / text / tool_use 各占一行), 每行都原样带着这个 stop_reason ——
600
+ // 终态消息的 thinking 行先落盘, 就已经是 end_turn 了。在它上面收口 = turn 提前
601
+ // 结束: 紧随其后的真正答案(text 行)落在已关闭的 turn 之外, 只能走 standalone,
602
+ // 且此后整轮的 tool/text 全部退化成散装气泡。终态消息里必然有 text 块 (含
603
+ // tool_use 的消息 stop_reason 恒为 "tool_use"), 且实测终态消息从不拆出第二个
604
+ // text 行 —— 所以"本行带 text 块"精确等价于"本消息的最后一行"。
605
+ if (isFinal && blocks.some((b) => b?.type === "text"))
606
+ out.push({ kind: "turn_end" });
607
+ // 软收口 (codebuddy): 这条消息写完了, 但说不出后面还有没有 function_call。
608
+ else if (line.softTurnEnd)
609
+ out.push({ kind: "turn_end", soft: true });
610
+ return out;
611
+ }
612
+ // CC (≥2.1.198) 在一轮真正跑完后写一行 `system/turn_duration` —— 权威收口信号。
613
+ // 补上 stop_reason 路径覆盖不到的场景: 被 esc 打断的 turn 只剩 thinking 行, 永远
614
+ // 没有终态 text 行, 否则 brief turn 会一直挂到 hardTimer。与 stop_reason 收口重复
615
+ // 时无害 —— closeBriefTurn / finalizeStream 都是幂等的。goal 模式下 Stop hook 拦住
616
+ // 了收尾, CC 不写这一行, 所以不会把自主执行提前踢出 goal 模式。
617
+ if (line.type === "system" && line.subtype === "turn_duration")
618
+ return [{ kind: "turn_end" }];
619
+ // Slash-command records for TUI-only commands land here as `type:"system"`,
620
+ // `subtype:"local_command"` — the invocation itself and, if present, a
621
+ // sibling `<local-command-stdout>` line carrying the rendered panel.
622
+ // • /context (2.1.139+): dumps the full panel as ANSI-decorated text.
623
+ // • /model on Claude Code 2.1.139 still lands under `type:"user"`, so this
624
+ // branch is currently /context-focused; other future TUI-only commands
625
+ // that follow the same shape will fall through the same anchoring.
626
+ if (line.type === "system" && line.subtype === "local_command" && typeof line.content === "string") {
627
+ const stdout = line.content.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)?.[1] ?? "";
628
+ if (!stdout.trim())
629
+ return []; // pure invocation record or empty stdout
630
+ const cleaned = stripAnsi(stdout);
631
+ const anchored = anchorSkillOutput(cleaned);
632
+ if (!anchored)
633
+ return [];
634
+ // Fenced code block so WeCom renders the aligned /context panel monospaced
635
+ // (the bar-chart columns only line up in a fixed-width bubble).
636
+ return [{ kind: "skill_output", body: "⚙️\n```\n" + anchored + "\n```" }];
637
+ }
638
+ return [];
639
+ };
640
+ // Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
641
+ // fenced block or table in a way that breaks rendering — see splitMarkdown.
642
+ const splitChunks = splitMarkdown;
643
+ // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line.
644
+ const TAG_HEADER_BUDGET = 32;
645
+ export const startMirrorTail = (deps) => {
646
+ const { log } = deps;
647
+ // A single logical tail follows ONE sessionId whose transcript file may move
648
+ // between sibling project dirs — Claude Code's EnterWorktree/ExitWorktree
649
+ // rename <sid>.jsonl into/out of <cwd>/.claude/worktrees/<name>, each cwd
650
+ // encoding to its own project dir. We keep a candidate path list (seeded with
651
+ // the attach-time path, grown lazily by sid on first miss) and ONE shared
652
+ // offset. Because the move is a rename (byte prefix preserved), the offset
653
+ // stays continuous across enter→work→exit: no re-dump, no missed lines, and
654
+ // switching back to the original path just resumes on the same offset.
655
+ // The sid search is scoped to the backend owning this transcript — a sid from
656
+ // another CLI is never ours, however recently it was written.
657
+ const candidates = [deps.jsonlPath];
658
+ // Newest-mtime candidate that currently exists (the move can briefly leave a
659
+ // stale sibling behind under copy-then-delete semantics; the destination wins).
660
+ const existing = () => candidates
661
+ .flatMap((p) => { try {
662
+ return [{ p, m: statSync(p).mtimeMs }];
663
+ }
664
+ catch {
665
+ return [];
666
+ } })
667
+ .sort((a, b) => b.m - a.m)[0]?.p;
668
+ // Live path: a known candidate if one exists; else the file relocated — find
669
+ // it by sid and cache so subsequent round-trips are a pure stat pick (no rescan).
670
+ const resolveLive = () => {
671
+ const known = existing();
672
+ if (known)
673
+ return known;
674
+ const found = findJsonlBySid(deps.sessionId, backendForPath(deps.jsonlPath));
675
+ if (found && !candidates.includes(found))
676
+ candidates.push(found);
677
+ return existing();
678
+ };
679
+ // Start at EOF — don't re-emit history. File may not exist yet (auto-spawn
680
+ // path: claude doesn't create the jsonl until it processes the first input);
681
+ // start at 0 in that case so we capture everything once it appears.
682
+ // Caller-provided startOffset wins (used by /clear migration to replay the
683
+ // already-written user line + any early assistant lines from offset 0).
684
+ let offset = deps.startOffset !== undefined
685
+ ? deps.startOffset
686
+ : (() => { const p = resolveLive(); return p ? statSync(p).size : 0; })();
687
+ let buffer = "";
688
+ let stopped = false;
689
+ // fs.watch binds one path; re-arm it on the live path whenever the file
690
+ // relocates. The 1s poll is the correctness floor either way.
691
+ let watcher;
692
+ let watchedPath = "";
693
+ const armWatch = (path) => {
694
+ if (path === watchedPath)
695
+ return;
696
+ watcher?.close();
697
+ watchedPath = path;
698
+ try {
699
+ watcher = watch(path, { persistent: false }, () => drain());
700
+ }
701
+ catch (e) {
702
+ watcher = undefined;
703
+ log.warn({ err: e.message }, "fs.watch failed; relying on poll");
704
+ }
705
+ };
706
+ const drain = () => {
707
+ if (stopped)
708
+ return;
709
+ const jsonlPath = resolveLive();
710
+ if (!jsonlPath)
711
+ return;
712
+ armWatch(jsonlPath);
713
+ let size;
714
+ try {
715
+ size = statSync(jsonlPath).size;
716
+ }
717
+ catch {
718
+ return;
719
+ }
720
+ if (size < offset) {
721
+ // file truncated/rotated — reset to new EOF
722
+ offset = size;
723
+ buffer = "";
724
+ return;
725
+ }
726
+ if (size === offset)
727
+ return;
728
+ const fd = openSync(jsonlPath, "r");
729
+ try {
730
+ const len = size - offset;
731
+ const buf = Buffer.alloc(len);
732
+ readSync(fd, buf, 0, len, offset);
733
+ offset = size;
734
+ buffer += buf.toString("utf8");
735
+ }
736
+ finally {
737
+ closeSync(fd);
738
+ }
739
+ let nl;
740
+ while ((nl = buffer.indexOf("\n")) !== -1) {
741
+ const line = buffer.slice(0, nl);
742
+ buffer = buffer.slice(nl + 1);
743
+ if (!line.trim())
744
+ continue;
745
+ for (const item of renderLine(line, deps))
746
+ deps.onItem(item);
747
+ }
748
+ };
749
+ const live0 = resolveLive();
750
+ if (live0)
751
+ armWatch(live0);
752
+ const poll = setInterval(drain, 1000);
753
+ log.info({ jsonlPath: deps.jsonlPath, startOffset: offset }, "mirror tail started");
754
+ return {
755
+ stop: () => {
756
+ stopped = true;
757
+ watcher?.close();
758
+ clearInterval(poll);
759
+ },
760
+ drain,
761
+ };
762
+ };
763
+ // Run a tmux subcommand, capturing stdout/stderr. Local helper to avoid
764
+ // pulling spawn-tmux's runTmux into the bridge module graph.
765
+ const tmuxRun = (args) => new Promise((resolve) => {
766
+ const p = spawn("tmux", args, { stdio: ["ignore", "pipe", "pipe"] });
767
+ let out = "", err = "";
768
+ p.stdout?.on("data", (c) => (out += c.toString("utf8")));
769
+ p.stderr?.on("data", (c) => (err += c.toString("utf8")));
770
+ p.on("error", (e) => resolve({ ok: false, stdout: "", stderr: e.message }));
771
+ p.on("close", (code) => resolve({ ok: code === 0, stdout: out, stderr: err }));
772
+ });
773
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
774
+ // Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
775
+ // `input` so the hook-side and the jsonl-side compute the same signature even
776
+ // when the model emits keys in arbitrary order.
777
+ const stableStringify = (v) => {
778
+ if (v === null || v === undefined)
779
+ return JSON.stringify(v);
780
+ if (typeof v !== "object")
781
+ return JSON.stringify(v);
782
+ if (Array.isArray(v))
783
+ return `[${v.map(stableStringify).join(",")}]`;
784
+ const obj = v;
785
+ const keys = Object.keys(obj).sort();
786
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
787
+ };
788
+ const toolUseSig = (name, input) => `${name}|${stableStringify(input)}`;
789
+ const RECENT_SIGS_MAX = 64;
790
+ // macOS clipboard image inject. Claude Code's TUI handles Ctrl+V by reading the
791
+ // system clipboard for image data and attaching it as an image content block in
792
+ // the next user turn — no Read tool call, no permission prompt. To trigger that
793
+ // path we (a) put image bytes onto the clipboard with the right AppleScript
794
+ // pasteboard class, (b) send a literal C-v keystroke to the live tmux pane.
795
+ //
796
+ // Pasteboard class per source format:
797
+ // .png → «class PNGf»
798
+ // .jpg/.jpeg → JPEG picture
799
+ // .gif → «class GIFf»
800
+ // .tiff/.tif → «class TIFF»
801
+ // Anything else (webp/heic/...) we transcode to PNG via `sips` first; sips ships
802
+ // with macOS so no extra dep. Files are cached next to the original; cleanup is
803
+ // left to the inbox dir's regular eviction.
804
+ //
805
+ // 注入策略 —— JXA 主路径 + AppleScript 兜底:
806
+ // codebuddy v2.72.0 的 release note 明确说"macOS 图片粘贴新增 JXA NSPasteboard
807
+ // 后备方案,兼容企业微信等第三方截图工具"——说明 codebuddy 的 TUI 在主路径
808
+ // (AppleScript «class PNGf») 读不到图时,会走 JXA `$.NSPasteboard.generalPasteboard`
809
+ // 后备。我们显式用 JXA 写一份 `NSPasteboardTypePNG` flavor,保证 codebuddy 的
810
+ // 后备路径一定能读到;同时 `NSPasteboardTypePNG` 和 `«class PNGf»` 在系统层是
811
+ // 同一个 UTI (public.png),Claude Code 的主路径读 JXA 写入的 flavor 也没问题。
812
+ // JXA 失败(极少见,比如 AppKit 框架加载失败)才回退到纯 AppleScript。
813
+ const PB_CLASS_BY_EXT = {
814
+ png: "«class PNGf»",
815
+ jpg: "JPEG picture",
816
+ jpeg: "JPEG picture",
817
+ gif: "«class GIFf»",
818
+ tif: "«class TIFF»",
819
+ tiff: "«class TIFF»",
820
+ };
821
+ const runProc = (cmd, args) => new Promise((resolve) => {
822
+ const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
823
+ let err = "";
824
+ p.stderr?.on("data", (c) => (err += c.toString("utf8")));
825
+ p.on("error", (e) => resolve({ ok: false, stderr: e.message }));
826
+ p.on("close", (code) => resolve({ ok: code === 0, stderr: err }));
827
+ });
828
+ const setMacClipboardImage = async (imgPath) => {
829
+ const ext = (imgPath.split(".").pop() ?? "").toLowerCase();
830
+ let pbClass = PB_CLASS_BY_EXT[ext];
831
+ let pathToUse = imgPath;
832
+ if (!pbClass) {
833
+ // Transcode to PNG so AppleScript can pull it onto the pasteboard.
834
+ const tmp = `${imgPath}.cb.png`;
835
+ const r = await runProc("sips", ["-s", "format", "png", imgPath, "--out", tmp]);
836
+ if (!r.ok)
837
+ return { ok: false, reason: `sips ${ext}→png failed: ${r.stderr.slice(-200)}` };
838
+ pathToUse = tmp;
839
+ pbClass = "«class PNGf»";
840
+ }
841
+ // POSIX path quoting: backslash-escape `\` and `"` for both AppleScript and
842
+ // JXA string literals (二者转义规则一致)。
843
+ const escaped = pathToUse.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
844
+ // 主路径:JXA 直接写 NSPasteboardTypePNG。clearContents 后单 flavor 写入,
845
+ // 同时覆盖 codebuddy v2.72.0 JXA 后备读路径和 Claude Code 主路径。
846
+ // (public.png UTI 与 «class PNGf» 同一,Claude Code 读这份不会回归。)
847
+ const jxa = [
848
+ "ObjC.import('AppKit');",
849
+ "const pb = $.NSPasteboard.generalPasteboard;",
850
+ "pb.clearContents();",
851
+ `const data = $.NSData.dataWithContentsOfFile("${escaped}");`,
852
+ "if (data.isNil()) $.abort('read failed');",
853
+ "pb.setData(data, forType: $.NSPasteboardTypePNG);",
854
+ ].join(" ");
855
+ const rJxa = await runProc("osascript", ["-l", "JavaScript", "-e", jxa]);
856
+ if (rJxa.ok)
857
+ return { ok: true };
858
+ // 兜底:JXA 失败才回退纯 AppleScript(用原 pbClass,可能是 JPEG/GIF/TIFF,
859
+ // 不强转 PNG —— 这条路径本来就是 Claude Code 历史验证过的)。
860
+ const script = `set the clipboard to (read POSIX file "${escaped}" as ${pbClass})`;
861
+ const rApple = await runProc("osascript", ["-e", script]);
862
+ if (!rApple.ok) {
863
+ return {
864
+ ok: false,
865
+ reason: `clipboard set failed: jxa=${rJxa.stderr.slice(-120)}; applescript=${rApple.stderr.slice(-120)}`,
866
+ };
867
+ }
868
+ return { ok: true };
869
+ };
870
+ // Pane fingerprint for verifying paste/submit. `rows` controls how far back
871
+ // from the bottom to capture: a wide window (12) for paste-landed (lenient,
872
+ // catches wrapped content / hint lines), a narrow window (5) for input-box-
873
+ // cleared. The narrow window matters after `/clear`: the buffer is almost
874
+ // empty, so claude's echo of the just-submitted message sits directly above
875
+ // the input box and would otherwise re-trigger the fingerprint, falsely
876
+ // flagging "Enter not honored".
877
+ const capturePaneTail = async (target, rows = 12) => {
878
+ const r = await tmuxRun(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
879
+ return r.ok ? r.stdout : "";
880
+ };
881
+ // Trim slash-command stdout to the useful section. TUI panels prepend a run
882
+ // of leading whitespace / bar-chart glyph rows before the human-readable
883
+ // title; we anchor at the first title line and drop everything above. The
884
+ // anchors here are the known /context titles — extend when new commands
885
+ // route through this branch.
886
+ const SKILL_ANCHORS = ["Context Usage"];
887
+ const anchorSkillOutput = (raw) => {
888
+ const lines = raw.split("\n");
889
+ const idx = lines.findIndex((l) => SKILL_ANCHORS.some((a) => l.includes(a)));
890
+ const sliced = idx >= 0 ? lines.slice(idx) : lines;
891
+ return sliced.join("\n").replace(/^\s+|\s+$/g, "");
892
+ };
893
+ // How far back to capture when recovering a pre-card preamble. Preambles before
894
+ // AskUserQuestion/ExitPlanMode are short; 40 rows covers wrap + the pending
895
+ // tool/picker below it without dragging in the previous turn.
896
+ const PANE_PREAMBLE_ROWS = 40;
897
+ const BULLET = "⏺"; // ⏺ — Claude Code's assistant/tool bullet
898
+ // Indented lines that are tool-execution summaries, not prose wrap. Filtered out
899
+ // when they sandwich between prose and the pending tool.
900
+ const TOOL_SUMMARY_RE = /^(⎿|… ?\+?\d|Ran |Read |Wrote |Edited |Listed |Searched |Found |Fetched |Called |Committed |Pushed |Pulled |Rebased |Staged |Updated )/u;
901
+ // After `⏺ `: a tool call (`Bash(…`) or a collapsed summary → not prose.
902
+ const isToolBullet = (afterBullet) => /^[A-Za-z_][\w.-]*\(/u.test(afterBullet) || TOOL_SUMMARY_RE.test(afterBullet);
903
+ // Pull the most recent assistant PROSE block out of a Claude Code TUI capture —
904
+ // the "why" that precedes a pending approval tool. CC renders prose as
905
+ // `⏺ <text>` + 2-space-indented wrapped lines; tool calls as `⏺ Name(…)` and
906
+ // summaries as indented `Ran…/Read…/⎿…`. Walk up from the bottom past the
907
+ // pending tool region to the last prose bullet, then collect its block.
908
+ // Best-effort: returns "" when nothing confident is found (caller sends the
909
+ // card alone — status-quo, no regression).
910
+ const extractPaneAssistantTail = (pane) => {
911
+ const lines = pane.replace(/[\s]+$/u, "").split("\n");
912
+ let start = -1;
913
+ for (let i = lines.length - 1; i >= 0; i--) {
914
+ const ln = lines[i];
915
+ if (/^❯\s+\S/u.test(ln))
916
+ return ""; // a user-message echo above → no preamble between it and here
917
+ // note: the empty input-box prompt (`❯ ` + NBSP, nothing after) is NOT a boundary — skip it
918
+ if (ln.startsWith(BULLET + " ")) {
919
+ if (!isToolBullet(ln.slice(2).trimStart())) {
920
+ start = i;
921
+ break;
922
+ }
923
+ // tool bullet (the gating tool) — keep scanning up for the prose above it
924
+ }
925
+ }
926
+ if (start === -1)
927
+ return "";
928
+ const out = [lines[start].slice(2).trimStart()];
929
+ for (let i = start + 1; i < lines.length; i++) {
930
+ const ln = lines[i];
931
+ if (ln.trim() === "") {
932
+ out.push("");
933
+ continue;
934
+ }
935
+ if (ln.startsWith(BULLET))
936
+ break; // next block starts
937
+ if (!/^\s{2}\S/u.test(ln))
938
+ break; // not a 2-space continuation
939
+ if (/[│┌┐└┘├┤┬┴┼◯○●☐☑▪▸▹]/u.test(ln))
940
+ break; // picker/table chrome
941
+ const body = ln.trimStart();
942
+ if (TOOL_SUMMARY_RE.test(body))
943
+ continue; // sandwiched tool summary
944
+ out.push(body);
945
+ }
946
+ while (out.length && out[out.length - 1] === "")
947
+ out.pop();
948
+ const text = out.join("\n").trim();
949
+ return text.length >= 12 ? text : "";
950
+ };
951
+ // Two fingerprints, derived from different ends of `text`:
952
+ // headFp — first 8 non-ws chars; used for "did paste land" against a wide
953
+ // window because long pastes wrap and the head sits near the top of the
954
+ // input box, possibly outside a tight tail capture.
955
+ // tailFp — last 8 non-ws chars; sits right above the cursor (bottom of the
956
+ // input box). Used for "did input box clear" against a narrow window:
957
+ // after Enter, claude echoes the user message ABOVE the input box, so a
958
+ // wide capture stays "dirty" forever; a tight 5-row capture sees only
959
+ // the input box itself, which DOES clear.
960
+ const fingerprints = (text) => {
961
+ const stripped = text.replace(/\s+/gu, "").trim();
962
+ return { headFp: stripped.slice(0, 8), tailFp: stripped.slice(-8) };
963
+ };
964
+ // Self-verifying inject. The cold-spawn race we guard against: paste lands
965
+ // but the trailing Enter is eaten while the TUI is still initializing, so
966
+ // the prompt sits typed-but-unsent. Strategy:
967
+ // 1. paste; wide-window poll for headFp → paste reached the input box.
968
+ // 2. settle; send Enter.
969
+ // 3. narrow-window poll (last 5 rows = just the input box, NOT the echo
970
+ // above) for tailFp absence → submit was honored.
971
+ // 4. on stuck-after-Enter, retry Enter once with extra settle.
972
+ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName) => {
973
+ log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
974
+ // 图片注入策略按后端分流:
975
+ // claude / claude-internal —— 走 macOS 剪贴板 + Ctrl+V,TUI 收到 \x16 后
976
+ // 直接读系统剪贴板的 PNGf flavor,附加为 image content block。
977
+ // codebuddy —— 它的 TUI 在 TMUX 环境下收到 C-v 后会先调
978
+ // syncTmuxToSystemClipboard()(执行 `tmux save-buffer - | pbcopy`),
979
+ // 这会用 tmux buffer 的文本内容覆盖系统剪贴板,把 daemon 刚写入的
980
+ // PNGf flavor 冲掉(pbcopy 只写 public.utf8-plain-text,NSImage 读不到)。
981
+ // 所以 codebuddy 走 C-v 必然失败 —— 改走 @<path> 文本提及,让 LLM
982
+ // 调 Read 工具读图(Read 支持图片,见 codebuddy tools-reference.md)。
983
+ // v2.52.4 之后 @<path> 不自动转 image block,但 LLM 看到路径会 Read。
984
+ if (backendName === "codebuddy") {
985
+ const refs = images.map((p) => `@${p}`);
986
+ const textWithRefs = refs.length ? (text ? `${refs.join("\n")}\n${text}` : refs.join("\n")) : text;
987
+ return injectViaTmuxText(target, textWithRefs, log, freshSpawn);
988
+ }
989
+ // Pump images first via clipboard+C-v so each one is attached as a separate
990
+ // image content block. Each C-v needs a brief settle for Claude Code's TUI
991
+ // to read the clipboard before the next overwrite. Fresh spawn extends.
992
+ const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
993
+ for (const imgPath of images) {
994
+ const cb = await setMacClipboardImage(imgPath);
995
+ if (!cb.ok) {
996
+ log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
997
+ continue;
998
+ }
999
+ const cv = await tmuxRun(["send-keys", "-t", target, "C-v"]);
1000
+ if (!cv.ok)
1001
+ return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1002
+ await sleepMs(IMG_SETTLE_MS);
1003
+ }
1004
+ // Image-only message: TUI input box now holds the attached images; press
1005
+ // Enter and we're done. No fingerprint to verify (the input itself is binary
1006
+ // image refs, not text).
1007
+ if (!text) {
1008
+ if (images.length === 0)
1009
+ return { ok: true };
1010
+ const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
1011
+ return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1012
+ }
1013
+ return injectViaTmuxText(target, text, log, freshSpawn);
1014
+ };
1015
+ // 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
1016
+ // 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
1017
+ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1018
+ // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1019
+ // started, transcript still loading): extended timings — bracketed-paste
1020
+ // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
1021
+ // pays the latency cost.
1022
+ const PASTE_VERIFY_MS = freshSpawn ? 6000 : 2500;
1023
+ const POST_PASTE_SETTLE_MS = freshSpawn ? 1500 : 400;
1024
+ const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
1025
+ const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
1026
+ const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
1027
+ const loadAndPaste = async () => {
1028
+ const loaded = await new Promise((resolve) => {
1029
+ const loader = spawn("tmux", ["load-buffer", "-"], { stdio: ["pipe", "ignore", "pipe"] });
1030
+ let lerr = "";
1031
+ loader.stderr?.on("data", (c) => (lerr += c.toString("utf8")));
1032
+ loader.on("error", (e) => resolve({ ok: false, reason: `tmux not found: ${e.message}` }));
1033
+ loader.on("close", (code) => {
1034
+ if (code !== 0)
1035
+ resolve({ ok: false, reason: `tmux load-buffer exit ${code}: ${lerr.slice(-200)}` });
1036
+ else
1037
+ resolve({ ok: true });
1038
+ });
1039
+ loader.stdin?.end(text);
1040
+ });
1041
+ if (!loaded.ok)
1042
+ return loaded;
1043
+ const pasted = await tmuxRun(["paste-buffer", "-p", "-d", "-t", target]);
1044
+ if (!pasted.ok)
1045
+ return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1046
+ return { ok: true };
1047
+ };
1048
+ let r = await loadAndPaste();
1049
+ if (!r.ok)
1050
+ return r;
1051
+ const { headFp, tailFp } = fingerprints(text);
1052
+ const POLL_MS = 100;
1053
+ const stripWs = (s) => s.replace(/\s+/gu, "");
1054
+ // Wide window catches a wrapped paste's head whether it's row -3 or row -10.
1055
+ const sawHead = async (timeoutMs) => {
1056
+ const t0 = Date.now();
1057
+ while (Date.now() - t0 < timeoutMs) {
1058
+ const pane = await capturePaneTail(target, 12);
1059
+ if (headFp && stripWs(pane).includes(headFp))
1060
+ return true;
1061
+ await sleepMs(POLL_MS);
1062
+ }
1063
+ return false;
1064
+ };
1065
+ // Narrow window = just the input box. tailFp is the chars next to the
1066
+ // cursor, so it's always inside this window pre-submit and gone post-submit.
1067
+ const inputBoxStillHasTail = async () => {
1068
+ const pane = await capturePaneTail(target, 5);
1069
+ return Boolean(tailFp) && stripWs(pane).includes(tailFp);
1070
+ };
1071
+ let pasteSeen = await sawHead(PASTE_VERIFY_MS);
1072
+ if (!pasteSeen) {
1073
+ // Paste fired before the TUI was reading — re-paste once after a back-off.
1074
+ log.warn({ target, headFp }, "mirror inject: paste headFp not seen, re-pasting");
1075
+ await sleepMs(RETRY_SETTLE_MS);
1076
+ r = await loadAndPaste();
1077
+ if (!r.ok)
1078
+ return r;
1079
+ pasteSeen = await sawHead(PASTE_VERIFY_MS);
1080
+ }
1081
+ // Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
1082
+ // Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
1083
+ await sleepMs(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1084
+ const sendEnter = async () => {
1085
+ const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
1086
+ return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1087
+ };
1088
+ const waitForCleared = async (timeoutMs) => {
1089
+ const t0 = Date.now();
1090
+ while (Date.now() - t0 < timeoutMs) {
1091
+ await sleepMs(POLL_MS);
1092
+ if (!(await inputBoxStillHasTail()))
1093
+ return true;
1094
+ }
1095
+ return false;
1096
+ };
1097
+ const e1 = await sendEnter();
1098
+ if (!e1.ok)
1099
+ return e1;
1100
+ if (!pasteSeen)
1101
+ log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
1102
+ if (!tailFp)
1103
+ return { ok: true }; // empty/whitespace text — nothing to verify
1104
+ if (await waitForCleared(CLEARED_TIMEOUT_MS))
1105
+ return { ok: true };
1106
+ // Input box still holds our tail — Enter was eaten (cold TUI) or the
1107
+ // bracketed-paste end hadn't been processed yet. One retry with extra
1108
+ // settle. We keep this to ONE retry to bound damage if our cleared-check
1109
+ // is wrong (would otherwise spam Enters into a real conversation).
1110
+ log.warn({ target, tailFp }, "mirror inject: input box still has text after Enter, retrying once");
1111
+ await sleepMs(RETRY_SETTLE_MS);
1112
+ const e2 = await sendEnter();
1113
+ if (!e2.ok)
1114
+ return e2;
1115
+ if (await waitForCleared(CLEARED_TIMEOUT_MS))
1116
+ return { ok: true };
1117
+ // freshSpawn fallback: claude --resume can take longer than our budget to
1118
+ // process bracketed-paste end. The Enter was sent twice; if it lands later,
1119
+ // claude will process the prompt and the user gets their reply. Trust it
1120
+ // rather than reporting a hard failure that the user actually got served.
1121
+ //
1122
+ // Warm-pane path also trusts: in practice tmux Enter is reliable once the
1123
+ // pane exists, and the verifier has structural false-positive risk —
1124
+ // tailFp (last 8 non-ws chars) can match the echo line directly above the
1125
+ // input box when it falls within the 5-row capture window. Surfacing
1126
+ // `[mirror] ✗` to the user when the prompt actually landed is worse than
1127
+ // accepting an extra no-op Enter on the rare true-stuck case.
1128
+ log.warn({ target, tailFp, freshSpawn }, "mirror inject: clear not observed, trusting submit");
1129
+ // Input box still held our text after two Enters. Usually the prompt landed
1130
+ // late (verifier false-positive), but it can also mean the target session is
1131
+ // busy / not consuming input (e.g. running a long task, or context full) —
1132
+ // the user's message would then silently go nowhere. Flag it uncertain so the
1133
+ // caller can hint the user, without reporting a hard failure.
1134
+ return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
1135
+ };
1136
+ const injectViaSpawn = (args) => {
1137
+ const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
1138
+ const bin = backendForPath(jsonlPath).bin;
1139
+ // Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
1140
+ // which Claude parses at submit time and inlines as image content blocks
1141
+ // without a model-decided Read tool turn.
1142
+ const refs = images.map((p) => `@${p}`).join("\n");
1143
+ const finalText = refs ? (text ? `${refs}\n${text}` : refs) : text;
1144
+ const cliArgs = [
1145
+ "-p",
1146
+ finalText,
1147
+ "--resume",
1148
+ sessionId,
1149
+ "--output-format",
1150
+ "stream-json",
1151
+ "--verbose",
1152
+ ...cfg.wrc.extraArgs,
1153
+ ];
1154
+ log.info({ sessionId, bin, len: text.length }, "mirror inject");
1155
+ return new Promise((resolve) => {
1156
+ const proc = spawn(bin, cliArgs, {
1157
+ cwd: expandHome(cfg.wrc.cwd),
1158
+ env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
1159
+ stdio: ["ignore", "ignore", "pipe"],
1160
+ });
1161
+ let stderrTail = "";
1162
+ let spawnError;
1163
+ proc.on("error", (err) => {
1164
+ spawnError = err;
1165
+ });
1166
+ proc.stderr?.on("data", (chunk) => {
1167
+ stderrTail = (stderrTail + chunk.toString("utf8")).slice(-1500);
1168
+ });
1169
+ proc.on("close", (code) => {
1170
+ if (spawnError) {
1171
+ log.error({ err: spawnError.message }, "mirror spawn error");
1172
+ resolve({ ok: false, reason: `spawn ${bin}: ${spawnError.message}` });
1173
+ return;
1174
+ }
1175
+ if (code !== 0) {
1176
+ log.error({ code, stderrTail: stderrTail.slice(-400) }, "mirror inject non-zero");
1177
+ resolve({ ok: false, reason: `${bin} exited ${code}: ${stderrTail.slice(-300)}` });
1178
+ return;
1179
+ }
1180
+ resolve({ ok: true });
1181
+ });
1182
+ });
1183
+ };
1184
+ const inject = (args) => {
1185
+ const target = (args.tmuxTarget ?? "").trim();
1186
+ if (!target)
1187
+ return injectViaSpawn(args);
1188
+ const backendName = backendForPath(args.jsonlPath).name;
1189
+ return injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false, backendName);
1190
+ };
1191
+ const queues = new Map();
1192
+ const enqueue = (key, job) => {
1193
+ const prev = queues.get(key) ?? Promise.resolve();
1194
+ const next = prev.then(job, job).catch(() => undefined);
1195
+ queues.set(key, next.finally(() => {
1196
+ if (queues.get(key) === next)
1197
+ queues.delete(key);
1198
+ }));
1199
+ return next;
1200
+ };
1201
+ export const startMirror = (deps) => {
1202
+ const { cfg, log, client } = deps;
1203
+ // Multi-mirror: each (sessionId, target) pair is one Attachment. Same
1204
+ // sessionId reattaches → replace. Same target with different sessionId →
1205
+ // replace too (one WeCom chat can only show one mirror at a time).
1206
+ const bySessionId = new Map();
1207
+ const byTarget = new Map();
1208
+ // Ring buffer of recently-injected user texts to suppress WeCom→CLI echo.
1209
+ const INJECT_TTL_MS = 60_000;
1210
+ const recentInjects = [];
1211
+ const rememberInject = (text) => {
1212
+ const t = text.trim();
1213
+ if (!t)
1214
+ return;
1215
+ recentInjects.push({ text: t, ts: Date.now() });
1216
+ // Slash commands land in the jsonl wrapped in <command-name>...</command-name>
1217
+ // tags; cleanUserText renders that as `/cmd args` (backticked). Push that
1218
+ // form too so the dedupe filter catches the tail's emission — without this,
1219
+ // /clear (and any other slash inject) echoes back as a quoted bubble.
1220
+ if (t.startsWith("/")) {
1221
+ const head = t.split(/\s+/, 1)[0] ?? t;
1222
+ const args = t.slice(head.length).trim();
1223
+ recentInjects.push({ text: `\`${head}${args ? ` ${args}` : ""}\``, ts: Date.now() });
1224
+ }
1225
+ if (recentInjects.length > 64)
1226
+ recentInjects.shift();
1227
+ };
1228
+ const isOwnInject = (text) => {
1229
+ const now = Date.now();
1230
+ const t = text.trim();
1231
+ for (let i = recentInjects.length - 1; i >= 0; i--) {
1232
+ const e = recentInjects[i];
1233
+ if (now - e.ts > INJECT_TTL_MS)
1234
+ continue;
1235
+ if (e.text === t)
1236
+ return true;
1237
+ }
1238
+ return false;
1239
+ };
1240
+ // Assistant prose pushed EARLY from a pane capture (pre-card preamble). The
1241
+ // same text lands in the jsonl later — CC flushes a tool-terminated turn only
1242
+ // when the tool resolves, i.e. after the card is answered — so the tail would
1243
+ // re-send it. Match normalized (ws-stripped) so a wrapped/dedented pane copy
1244
+ // equals the clean jsonl text; boundary match covers a pane that only caught
1245
+ // part of a long preamble. findAssistantSend peeks (dup-guard for the per-
1246
+ // question flushBeforeCard re-calls); isOwnAssistantSend consumes one-shot.
1247
+ const recentAssistantSends = [];
1248
+ const normAssistant = (s) => s.replace(/\s+/gu, "");
1249
+ const findAssistantSend = (text) => {
1250
+ const sig = normAssistant(text);
1251
+ if (sig.length < 12)
1252
+ return -1;
1253
+ const cutoff = Date.now() - INJECT_TTL_MS;
1254
+ for (let i = recentAssistantSends.length - 1; i >= 0; i--) {
1255
+ const e = recentAssistantSends[i];
1256
+ if (e.ts < cutoff)
1257
+ continue;
1258
+ if (sig === e.sig || sig.startsWith(e.sig) || e.sig.startsWith(sig) || sig.endsWith(e.sig) || e.sig.endsWith(sig))
1259
+ return i;
1260
+ }
1261
+ return -1;
1262
+ };
1263
+ const rememberAssistantSend = (text) => {
1264
+ const sig = normAssistant(text);
1265
+ if (sig.length < 12)
1266
+ return;
1267
+ recentAssistantSends.push({ sig, ts: Date.now() });
1268
+ if (recentAssistantSends.length > 32)
1269
+ recentAssistantSends.shift();
1270
+ };
1271
+ const isOwnAssistantSend = (text) => {
1272
+ const i = findAssistantSend(text);
1273
+ if (i === -1)
1274
+ return false;
1275
+ recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
1276
+ return true;
1277
+ };
1278
+ // ── Typewriter stream lifecycle ────────────────────────────────────
1279
+ // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1280
+ // original inbound. SDK queues replyStream calls per req_id, sends serially
1281
+ // (5s ack timeout each). After 6 min the server stops accepting refreshes.
1282
+ // We keep the stream open until either (a) the next inbound supersedes it,
1283
+ // (b) the hard cap fires (just under 6 min), (c) server rejects (s.dead),
1284
+ // or (d) we hit the byte cap. No idle-based close — claude can sit thinking
1285
+ // for >60s mid-turn and we don't want to drop the bubble while it's chewing.
1286
+ const FLUSH_MS = 250;
1287
+ const HARD_TIMEOUT_MS = 350_000;
1288
+ /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1289
+ * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1290
+ * 这一段, 与模型思考/工具执行时长无关。 */
1291
+ const SOFT_TURN_END_MS = 4_000;
1292
+ const STREAM_SOFT_CAP = 18_000;
1293
+ const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
1294
+ const turnRegistry = new Map();
1295
+ const evictTurns = () => {
1296
+ const now = Date.now();
1297
+ for (const [k, v] of turnRegistry)
1298
+ if (v.expiresAt < now)
1299
+ turnRegistry.delete(k);
1300
+ };
1301
+ const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
1302
+ const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
1303
+ const detailCardFor = (s, target) => {
1304
+ if (s.tools.length === 0)
1305
+ return undefined;
1306
+ const tag = tagOfTarget(target);
1307
+ const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
1308
+ return {
1309
+ card_type: "button_interaction",
1310
+ main_title: { title: `${titlePrefix}本轮工具调用` },
1311
+ sub_title_text: `共 ${s.tools.length} 次调用,点击查看详情`,
1312
+ task_id: s.turnId,
1313
+ button_list: [{ text: "查看详情", style: 1, key: `${TOOL_DETAIL_PREFIX}${s.turnId}` }],
1314
+ };
1315
+ };
1316
+ const flushStream = async (s) => {
1317
+ s.flushTimer = undefined;
1318
+ if (s.closed || s.dead || s.acc === s.lastSent)
1319
+ return;
1320
+ const content = s.acc;
1321
+ s.lastSent = content;
1322
+ try {
1323
+ await client.replyStream(s.frame, s.streamId, withSessionTag(s.target, content || " "), false);
1324
+ log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1325
+ }
1326
+ catch (e) {
1327
+ log.warn({ turnId: s.turnId, err: e.message }, "stream flush failed; marking dead");
1328
+ s.dead = true;
1329
+ }
1330
+ };
1331
+ const scheduleFlush = (s) => {
1332
+ if (s.flushTimer || s.closed || s.dead)
1333
+ return;
1334
+ s.flushTimer = setTimeout(() => void flushStream(s), FLUSH_MS);
1335
+ };
1336
+ const finalizeStream = async (a, s) => {
1337
+ if (s.closed)
1338
+ return;
1339
+ s.closed = true;
1340
+ if (s.flushTimer) {
1341
+ clearTimeout(s.flushTimer);
1342
+ s.flushTimer = undefined;
1343
+ }
1344
+ if (s.idleTimer) {
1345
+ clearTimeout(s.idleTimer);
1346
+ s.idleTimer = undefined;
1347
+ }
1348
+ if (s.hardTimer) {
1349
+ clearTimeout(s.hardTimer);
1350
+ s.hardTimer = undefined;
1351
+ }
1352
+ if (!s.dead) {
1353
+ const card = detailCardFor(s, a.target);
1354
+ try {
1355
+ if (card) {
1356
+ s.cardSent = true;
1357
+ await client.replyStreamWithCard(s.frame, s.streamId, withSessionTag(a.target, s.acc || " "), true, { templateCard: card });
1358
+ }
1359
+ else {
1360
+ await client.replyStream(s.frame, s.streamId, withSessionTag(a.target, s.acc || " "), true);
1361
+ }
1362
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
1363
+ }
1364
+ catch (e) {
1365
+ log.warn({ sessionId: a.sessionId, turnId: s.turnId, err: e.message }, "stream finalize failed");
1366
+ s.dead = true;
1367
+ }
1368
+ }
1369
+ else {
1370
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length }, "stream finalize (dead)");
1371
+ }
1372
+ if (s.tools.length > 0) {
1373
+ turnRegistry.set(s.turnId, {
1374
+ tools: s.tools,
1375
+ target: a.target,
1376
+ expiresAt: Date.now() + TOOL_DETAIL_TTL_MS,
1377
+ });
1378
+ evictTurns();
1379
+ }
1380
+ if (a.liveStream === s)
1381
+ a.liveStream = undefined;
1382
+ };
1383
+ const openStream = (a, frame, streamId) => {
1384
+ const s = {
1385
+ turnId: newTurnId(),
1386
+ frame, streamId,
1387
+ target: a.target,
1388
+ acc: "", lastSent: "",
1389
+ capped: false, closed: false, dead: false, cardSent: false,
1390
+ tools: [], sawTool: false,
1391
+ };
1392
+ s.hardTimer = setTimeout(() => void finalizeStream(a, s), HARD_TIMEOUT_MS);
1393
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, streamId }, "stream open");
1394
+ return s;
1395
+ };
1396
+ // Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
1397
+ // pushes from a single mirror stay ordered; different mirrors run in parallel.
1398
+ const sendStandalone = (a, content) => {
1399
+ const chatId = stripPrincipalPrefix(a.target);
1400
+ // Tag AFTER splitting — one header per bubble, so chunk 2..N stay
1401
+ // attributable to the session instead of arriving anonymous.
1402
+ const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkChars - TAG_HEADER_BUDGET));
1403
+ const chunks = pieces.map((p, i) => withSessionTag(a.target, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1404
+ a.standalonePending = a.standalonePending
1405
+ .then(async () => {
1406
+ for (const c of chunks) {
1407
+ try {
1408
+ await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
1409
+ }
1410
+ catch (e) {
1411
+ log.warn({ sessionId: a.sessionId, err: e.message }, "standalone push failed");
1412
+ }
1413
+ }
1414
+ })
1415
+ .catch(() => undefined);
1416
+ };
1417
+ // Pre-card preamble recovery. Called right before an approval card when the
1418
+ // gating tool_use is NOT yet in the jsonl (mirror mode: CC defers the whole
1419
+ // tool-terminated turn's flush until the tool resolves — after the card is
1420
+ // answered). The "why" text is therefore only in the live pane; capture it,
1421
+ // push it ahead of the card, and remember it so the later jsonl-tailed copy
1422
+ // is dropped as a dup. Enqueues on standalonePending, which flushBeforeCard
1423
+ // awaits — so the card lands after. Fail-safe: any miss → just the card.
1424
+ const sendPanePreamble = async (a) => {
1425
+ if (!cfg.wrc.mirror.panePreamble || !a.tmuxPane)
1426
+ return;
1427
+ try {
1428
+ const pane = await capturePaneTail(a.tmuxPane, PANE_PREAMBLE_ROWS);
1429
+ const text = extractPaneAssistantTail(pane);
1430
+ if (!text || findAssistantSend(text) !== -1)
1431
+ return; // nothing new / already queued
1432
+ rememberAssistantSend(text);
1433
+ sendStandalone(a, text);
1434
+ log.info({ sessionId: a.sessionId, len: text.length }, "flushBeforeCard: pushed pane preamble before card");
1435
+ }
1436
+ catch (e) {
1437
+ log.warn({ sessionId: a.sessionId, err: e.message }, "pane preamble capture failed");
1438
+ }
1439
+ };
1440
+ // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1441
+ // 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
1442
+ const flushStandalone = (a) => {
1443
+ const buf = a.standaloneBuf;
1444
+ if (!buf)
1445
+ return;
1446
+ a.standaloneBuf = undefined;
1447
+ sendStandalone(a, buf.parts.join("\n\n"));
1448
+ };
1449
+ const enqueueStandalone = (a, content) => {
1450
+ const ms = cfg.wrc.mirror.standaloneDebounceMs;
1451
+ if (ms <= 0) {
1452
+ sendStandalone(a, content);
1453
+ return;
1454
+ }
1455
+ if (a.standaloneBuf) {
1456
+ clearTimeout(a.standaloneBuf.timer);
1457
+ a.standaloneBuf.parts.push(content);
1458
+ a.standaloneBuf.timer = setTimeout(() => flushStandalone(a), ms);
1459
+ return;
1460
+ }
1461
+ a.standaloneBuf = {
1462
+ parts: [content],
1463
+ timer: setTimeout(() => flushStandalone(a), ms),
1464
+ };
1465
+ };
1466
+ const recordToolEntry = (s, item) => {
1467
+ if (item.kind === "tool_use") {
1468
+ for (const c of item.calls) {
1469
+ s.tools.push({ toolUseId: c.toolUseId, name: c.name, input: c.input });
1470
+ }
1471
+ }
1472
+ else if (item.kind === "tool_result") {
1473
+ // Match by toolUseId; if not found, append a standalone result entry.
1474
+ const existing = item.toolUseId
1475
+ ? s.tools.find((t) => t.toolUseId === item.toolUseId && t.result === undefined)
1476
+ : undefined;
1477
+ if (existing)
1478
+ existing.result = item.full;
1479
+ else
1480
+ s.tools.push({ toolUseId: item.toolUseId, name: "(result)", input: undefined, result: item.full });
1481
+ }
1482
+ };
1483
+ // ── Outbound deferral (DEFERRED / AWAITING_APPR state machine) ────────
1484
+ // 进入 AWAITING_APPR 的唯一触发口是 daemon 真要发卡前调的 flushBeforeCard。
1485
+ // 历史上 mirror 这边还做过一次本地 needsApproval 预判直接早闪 standalone, 但那
1486
+ // 条路无法预知 hook 会不会被 auto / bypass / 自定义 self-call 放行 — 预判错时
1487
+ // 就会出现 standalone 已经发出、随即又新开 stream 的"气泡分裂"。现在去掉,
1488
+ // 让 flushBeforeCard 作为单一信号: 发卡的真实路径 → flush as standalone,
1489
+ // 不发卡的所有路径 (auto mode、cache、bypass) → deferMs 计时器原地促进到 STREAMING。
1490
+ const renderBuf = (buf) => buf.map((i) => ("body" in i ? i.body : "")).filter(Boolean).join("\n\n");
1491
+ // Drain any pending standalone debounce so the prior turn's tail content
1492
+ // doesn't sandwich into our pre-card flush. Caller already ensured `outbound`
1493
+ // points at a deferred slot.
1494
+ const flushPendingStandalone = (a) => {
1495
+ if (!a.standaloneBuf)
1496
+ return;
1497
+ clearTimeout(a.standaloneBuf.timer);
1498
+ flushStandalone(a);
1499
+ };
1500
+ // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
1501
+ // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
1502
+ const promoteToStandalone = (a) => {
1503
+ const out = a.outbound;
1504
+ if (out?.kind !== "deferred")
1505
+ return;
1506
+ clearTimeout(out.timer);
1507
+ const md = renderBuf(out.buf);
1508
+ a.outbound = { kind: "awaiting_appr", frame: out.frame, streamId: out.streamId };
1509
+ flushPendingStandalone(a);
1510
+ if (md)
1511
+ sendStandalone(a, md);
1512
+ log.info({ sessionId: a.sessionId, items: out.buf.length, mdLen: md.length }, "outbound: DEFERRED → AWAITING_APPR (needs-approval flush)");
1513
+ };
1514
+ // Path C: turn ended inside the deferral window (fast pure-text reply, or
1515
+ // an end-to-end tool turn that happened to fit in the window). The inbound's
1516
+ // streamId is already showing a "loading" bubble in WeCom (server polls us
1517
+ // for every msg.msgid; we haven't replied to this one) — sending a separate
1518
+ // standalone leaves that loading bubble dangling until WeCom's ~6-min
1519
+ // server-side timeout. Push the buffered content into the held streamId
1520
+ // with finish=true so the bubble fills + closes in one shot.
1521
+ //
1522
+ // Split mirrors the STREAMING tool→FINAL_text rule: if buf saw a tool AND
1523
+ // ends with a run of final-text items, peel that run out into a standalone
1524
+ // so the answer gets its own bubble (preview = real answer, not tool noise).
1525
+ // Pure-text turn or no trailing final text → no split, all into streamId.
1526
+ const exitDeferredAsFinalStream = (a) => {
1527
+ const out = a.outbound;
1528
+ if (out?.kind !== "deferred")
1529
+ return;
1530
+ clearTimeout(out.timer);
1531
+ const buf = out.buf;
1532
+ const sawTool = buf.some((i) => i.kind === "tool_use" || i.kind === "tool_result");
1533
+ let bubbleEnd = buf.length;
1534
+ if (sawTool) {
1535
+ while (bubbleEnd > 0) {
1536
+ const it = buf[bubbleEnd - 1];
1537
+ if (it.kind === "text" && it.final === true)
1538
+ bubbleEnd--;
1539
+ else
1540
+ break;
1541
+ }
1542
+ }
1543
+ const bubbleItems = buf.slice(0, bubbleEnd);
1544
+ const trailingFinal = buf.slice(bubbleEnd);
1545
+ const bubbleMd = renderBuf(bubbleItems);
1546
+ const trailingMd = renderBuf(trailingFinal);
1547
+ a.outbound = undefined;
1548
+ flushPendingStandalone(a);
1549
+ void (async () => {
1550
+ try {
1551
+ await client.replyStream(out.frame, out.streamId, withSessionTag(a.target, bubbleMd || " "), true);
1552
+ }
1553
+ catch (e) {
1554
+ log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
1555
+ if (bubbleMd)
1556
+ sendStandalone(a, bubbleMd);
1557
+ }
1558
+ if (trailingMd)
1559
+ sendStandalone(a, trailingMd);
1560
+ })();
1561
+ log.info({ sessionId: a.sessionId, items: buf.length, bubbleLen: bubbleMd.length, trailingLen: trailingMd.length, sawTool }, "outbound: DEFERRED → IDLE (turn_end, finalized to streamId)");
1562
+ };
1563
+ // Path B: deferral timer fired without a needs-approval tool. Open a normal
1564
+ // stream and replay the buffer through onItem — it'll flow through the
1565
+ // STREAMING branch since outbound is now undefined.
1566
+ const promoteToStream = (a) => {
1567
+ const out = a.outbound;
1568
+ if (out?.kind !== "deferred")
1569
+ return;
1570
+ const { buf, frame, streamId } = out;
1571
+ a.outbound = undefined;
1572
+ const s = openStream(a, frame, streamId);
1573
+ a.liveStream = s;
1574
+ if (buf.length === 0) {
1575
+ // Empty buffer — claude still thinking. Send the "…" ack so the user
1576
+ // sees the bubble; subsequent items grow it as today.
1577
+ void (async () => {
1578
+ try {
1579
+ await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false);
1580
+ }
1581
+ catch (e) {
1582
+ log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
1583
+ }
1584
+ })();
1585
+ }
1586
+ log.info({ sessionId: a.sessionId, items: buf.length, turnId: s.turnId }, "outbound: DEFERRED → STREAMING (timer)");
1587
+ for (const item of buf)
1588
+ onItem(a, item);
1589
+ };
1590
+ const enterDeferred = (a, frame, streamId) => {
1591
+ // Safety net: if nothing ever arrives (inject stuck, claude crashed, or
1592
+ // claude went silent forever), promote-to-stream after 5 min to clean up.
1593
+ // The REAL deferral window (cfg.wrc.mirror.outboundDeferMs) is armed by
1594
+ // handleDeferredItem when the first tail item lands — measuring from "claude
1595
+ // starts producing" not from "dispatch starts", which would otherwise fire
1596
+ // during inject latency (typical 1-4s for tmux paste verify) + post-inject
1597
+ // thinking gap (often 5-15s for non-trivial prompts) and falsely promote
1598
+ // an empty buffer before any tool_use can be evaluated for approval-needs.
1599
+ const SAFETY_MS = 5 * 60_000;
1600
+ const timer = setTimeout(() => promoteToStream(a), SAFETY_MS);
1601
+ a.outbound = { kind: "deferred", buf: [], frame, streamId, timer };
1602
+ log.info({ sessionId: a.sessionId, safetyMs: SAFETY_MS }, "outbound: IDLE → DEFERRED (safety net)");
1603
+ };
1604
+ // Buffer / decide while in DEFERRED. user_text is CLI-side typing (not the
1605
+ // WeCom inbound), unrelated to this turn — drop. tool_use triggers Path A
1606
+ // when ANY parallel call needs approval. First item arms the short window;
1607
+ // subsequent items don't reset (bounded promote delay).
1608
+ const handleDeferredItem = (a, item) => {
1609
+ const out = a.outbound;
1610
+ if (out?.kind !== "deferred")
1611
+ return;
1612
+ if (item.kind === "turn_end") {
1613
+ exitDeferredAsFinalStream(a);
1614
+ return;
1615
+ }
1616
+ if (item.kind === "user_text")
1617
+ return;
1618
+ // Skill outputs (e.g. /model) bypass deferred filtering — emit directly
1619
+ // as a standalone bubble so the user sees the result immediately.
1620
+ if (item.kind === "skill_output") {
1621
+ enqueueStandalone(a, item.body);
1622
+ return;
1623
+ }
1624
+ const wasEmpty = out.buf.length === 0;
1625
+ out.buf.push(item);
1626
+ if (wasEmpty) {
1627
+ // First activity from claude — swap the safety net for the short defer
1628
+ // window. flushBeforeCard 会在真要发卡前把 buf 转 AWAITING_APPR; 没卡可发
1629
+ // 时这个计时器到点就 promoteToStream, 让 bypass/auto 场景也只走一条 stream。
1630
+ clearTimeout(out.timer);
1631
+ const deferMs = cfg.wrc.mirror.outboundDeferMs;
1632
+ out.timer = setTimeout(() => promoteToStream(a), deferMs);
1633
+ log.info({ sessionId: a.sessionId, deferMs, kind: item.kind }, "outbound: first item → short defer armed");
1634
+ }
1635
+ };
1636
+ // ── Brief mode ────────────────────────────────────────────────────────
1637
+ // 每个 turn 挂一条 loading 气泡 (起始 "…" finish=false, 不立刻关掉)。收口时:
1638
+ // • 无工具 → 直接把 Claude 最终文本 (或 slash 命令的 skill_output) 写进这条气泡。
1639
+ // • 有工具 → 气泡收成 "✴️ 详情链接", 最终文本另发一条 standalone。
1640
+ // 其它所有 item 只写入 turn detail store。
1641
+ // 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
1642
+ const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
1643
+ // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
1644
+ // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
1645
+ const briefDetailLink = (turnId) => `✴️ [View chat details](${buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId)})`;
1646
+ // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1647
+ // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1648
+ const finishBubble = async (a, b, content) => {
1649
+ if (!b || b.done)
1650
+ return;
1651
+ b.done = true;
1652
+ clearTimeout(b.hardTimer);
1653
+ if (a.briefBubble === b)
1654
+ a.briefBubble = undefined;
1655
+ try {
1656
+ await client.replyStream(b.frame, b.streamId, withSessionTag(a.target, content || " "), true);
1657
+ }
1658
+ catch (e) {
1659
+ log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
1660
+ if (content.trim())
1661
+ sendStandalone(a, content);
1662
+ }
1663
+ };
1664
+ const finishBriefBubble = (a, content) => finishBubble(a, a.briefBubble, content);
1665
+ // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
1666
+ const openBriefTurn = (a, q) => {
1667
+ a.briefTurnId = q.turnId;
1668
+ a.briefBubble = q.bubble;
1669
+ a.briefIsSlash = q.isSlash;
1670
+ a.briefHadTool = false;
1671
+ a.briefConcluded = false;
1672
+ a.briefLastText = undefined;
1673
+ log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
1674
+ };
1675
+ // WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
1676
+ // 上一 turn 还在跑时绝不能强关它 —— CC 那边新消息也是排队的, 当前轮的后半段还会
1677
+ // 继续落盘, 强关会把它们改挂到新 turn 上, 旧 turn 页则提前变「已完成」。
1678
+ const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
1679
+ const turnId = newTurnId();
1680
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: userQuery.trim() || undefined });
1681
+ // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
1682
+ const bubble = { frame, streamId, hardTimer: undefined, done: false };
1683
+ const q = { turnId, bubble, isSlash };
1684
+ bubble.hardTimer = setTimeout(() => {
1685
+ // 到点还在排队 = 前一个 turn 卡死或漏收 turn_end。强关它放行 —— 队列必须能自愈,
1686
+ // 否则这条消息的产出会一直记到那个僵尸 turn 上。气泡此时已过 WeCom 更新窗口,
1687
+ // 收成详情链接即可, 真正要紧的是让本 turn 激活。
1688
+ if (a.briefQueue?.includes(q)) {
1689
+ log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
1690
+ closeBriefTurn(a);
1691
+ }
1692
+ void finishBubble(a, bubble, briefDetailLink(turnId));
1693
+ }, HARD_TIMEOUT_MS);
1694
+ if (a.briefTurnId) {
1695
+ (a.briefQueue ??= []).push(q);
1696
+ log.info({ sessionId: a.sessionId, turnId, queued: a.briefQueue.length }, "brief: turn queued (previous still running)");
1697
+ }
1698
+ else {
1699
+ openBriefTurn(a, q);
1700
+ }
1701
+ try {
1702
+ await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
1703
+ }
1704
+ catch (e) {
1705
+ log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
1706
+ }
1707
+ };
1708
+ // 无气泡 turn。CLI 侧自己开的一轮 (WeCom 从没发过消息, 拿不到 frame/streamId), 以及
1709
+ // 收口后仍有 item 补写进来的情况, 都要有一个 turn 承接 —— 否则每条 tool/text 都掉进
1710
+ // fallback standalone, 一轮工具密集的对话能在群里刷出几十条散装气泡。这里只推一条
1711
+ // 详情链接当入口, 其余全部收进 turn 页, 与 WeCom 侧发起的 turn 表现一致。
1712
+ const ensureBriefTurn = (a) => {
1713
+ if (a.briefTurnId)
1714
+ return;
1715
+ const turnId = newTurnId();
1716
+ const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
1717
+ a.pendingBriefQuery = undefined;
1718
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: query || undefined });
1719
+ a.briefTurnId = turnId;
1720
+ a.briefBubble = undefined;
1721
+ a.briefIsSlash = false;
1722
+ a.briefHadTool = false;
1723
+ a.briefConcluded = false;
1724
+ a.briefLastText = undefined;
1725
+ enqueueStandalone(a, briefDetailLink(turnId));
1726
+ log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
1727
+ };
1728
+ // 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
1729
+ // 简单 turn —— 立刻把 loading 气泡收成详情链接, 用户马上能点进去看实时刷新的时间线,
1730
+ // 不必等 turn 收口 (旧逻辑 URL 只在 closeBriefTurn 才写, 刷新太慢)。收链接即视作
1731
+ // briefHadTool, 最终结论照常另发 standalone; closeBriefTurn 见气泡已 done 会跳过。
1732
+ const earlyLinkBubble = (a) => {
1733
+ if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
1734
+ return;
1735
+ a.briefHadTool = true;
1736
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId));
1737
+ };
1738
+ // 本轮结论落地, 只生效一次:
1739
+ // • 无工具 → 结论 + 详情链接一并写进 loading 气泡 (仍是一条消息);
1740
+ // • 有工具 / 无气泡 turn → 气泡留给 closeBriefTurn 收详情链接, 结论另发 standalone。
1741
+ // 每个 turn 都必须留一个可回溯入口, 单句回答也要带链接。
1742
+ const concludeBriefTurn = (a, body) => {
1743
+ const turnId = a.briefTurnId;
1744
+ if (!turnId || a.briefConcluded || !body.trim())
1745
+ return;
1746
+ a.briefConcluded = true;
1747
+ if (a.briefHadTool || !a.briefBubble)
1748
+ sendStandalone(a, body);
1749
+ else
1750
+ void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId)}`);
1751
+ // 排队中的 turn 已经建了记录但还没跑, 不能被这把扫帚扫成「已完成」。
1752
+ const keep = [turnId, ...(a.briefQueue ?? []).map((q) => q.turnId)];
1753
+ recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
1754
+ };
1755
+ /** soft=true: 收口信号来自静默期确认 (后端无终句标记), 拿本轮最后一句 text 当结论。
1756
+ * 硬信号路径上结论早已由 final text 落地, briefConcluded 会挡住重复推送。 */
1757
+ const closeBriefTurn = (a, soft = false) => {
1758
+ if (!a.briefTurnId)
1759
+ return;
1760
+ if (soft)
1761
+ concludeBriefTurn(a, a.briefLastText ?? "");
1762
+ // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
1763
+ // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
1764
+ if (a.briefBubble && !a.briefBubble.done) {
1765
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId));
1766
+ }
1767
+ recordTurnClose(a.briefTurnId);
1768
+ log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
1769
+ a.briefTurnId = undefined;
1770
+ a.briefBubble = undefined;
1771
+ a.briefHadTool = false;
1772
+ a.briefIsSlash = false;
1773
+ a.briefConcluded = false;
1774
+ a.briefLastText = undefined;
1775
+ // 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
1776
+ const next = a.briefQueue?.shift();
1777
+ if (next)
1778
+ openBriefTurn(a, next);
1779
+ };
1780
+ // Route one RenderItem into the turn store. final text 会额外作为 standalone 发群。
1781
+ const handleBriefItem = (a, item) => {
1782
+ const turnId = a.briefTurnId;
1783
+ if (!turnId)
1784
+ return;
1785
+ const now = Date.now();
1786
+ if (item.kind === "tool_use") {
1787
+ a.briefHadTool = true; // 有工具 → 气泡收口写详情链接, 结论另发 standalone
1788
+ earlyLinkBubble(a); // 立刻推详情链接, 不等 turn 收口
1789
+ for (const c of item.calls) {
1790
+ // 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
1791
+ if (c.toolUseId) {
1792
+ recordTool({
1793
+ id: c.toolUseId,
1794
+ toolName: c.name,
1795
+ toolInput: c.input,
1796
+ sessionId: a.sessionId,
1797
+ target: a.target,
1798
+ });
1799
+ }
1800
+ recordTurnItem(turnId, {
1801
+ t: "tool_use",
1802
+ toolUseId: c.toolUseId,
1803
+ toolName: c.name,
1804
+ toolInput: c.input,
1805
+ ts: now,
1806
+ });
1807
+ }
1808
+ return;
1809
+ }
1810
+ if (item.kind === "tool_result") {
1811
+ earlyLinkBubble(a);
1812
+ recordTurnItem(turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
1813
+ return;
1814
+ }
1815
+ if (item.kind === "text") {
1816
+ recordTurnItem(turnId, { t: "text", body: item.body, ts: now, final: item.final === true });
1817
+ a.briefLastText = item.body; // 软收口时拿它当结论 (那条路径上没有 final 标记)
1818
+ // final===false 才是"确知的中途输出"; undefined 是后端说不清, 不能据此提前收气泡。
1819
+ if (item.final === false)
1820
+ earlyLinkBubble(a);
1821
+ if (item.final === true)
1822
+ concludeBriefTurn(a, item.body);
1823
+ return;
1824
+ }
1825
+ if (item.kind === "turn_end") {
1826
+ closeBriefTurn(a, item.soft === true);
1827
+ return;
1828
+ }
1829
+ if (item.kind === "turn_usage") {
1830
+ recordTurnUsage(turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
1831
+ return;
1832
+ }
1833
+ if (item.kind === "skill_output") {
1834
+ recordTurnItem(turnId, { t: "text", body: item.body, ts: now });
1835
+ // slash 命令 (/context…) 的 skill_output 即本轮答案 (无 final text 收口) —— 写进
1836
+ // loading 气泡。非 slash 场景的 skill_output (task 通知等) 是中间反馈, 照旧 standalone。
1837
+ if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
1838
+ void finishBriefBubble(a, item.body);
1839
+ }
1840
+ else {
1841
+ sendStandalone(a, item.body);
1842
+ }
1843
+ return;
1844
+ }
1845
+ // user_text (CLI 侧输入) 在 brief 下丢弃 — turn 视角不需要展示。
1846
+ };
1847
+ const goalBanner = (cond) => cond
1848
+ ? `🎯 目标已设置,进入自主执行:${cond}\n进度将实时推送。`
1849
+ : "🎯 进入目标自主执行模式,进度将实时推送。";
1850
+ // Enter goal-progress mode. The /goal was (usually) sent from WeCom, so a brief
1851
+ // loading bubble is open — finalize it to the goal banner and close the brief
1852
+ // turn so its turn_end-gated state doesn't dangle for the whole (never-ending)
1853
+ // run. When goal was set from the CLI there's no brief turn — just announce.
1854
+ const enterGoalMode = (a, condition) => {
1855
+ if (a.goalActive)
1856
+ return; // 幂等: 重放/重复 marker 不再重发 banner
1857
+ const banner = goalBanner(condition);
1858
+ if (a.briefBubble && !a.briefBubble.done)
1859
+ void finishBriefBubble(a, banner);
1860
+ else
1861
+ enqueueStandalone(a, banner);
1862
+ // 连同排队中的 turn 一起收掉: goal 期间所有 item 走 handleGoalItem, 排队的 turn
1863
+ // 永远等不到自己的产出。closeBriefTurn 会逐个放行再关闭, 每个都留下详情链接。
1864
+ while (a.briefTurnId)
1865
+ closeBriefTurn(a); // bubble now done → closeBriefTurn skips re-finalizing
1866
+ a.goalActive = true;
1867
+ log.info({ sessionId: a.sessionId, target: a.target, condition }, "goal: entered progress mode");
1868
+ };
1869
+ // Route items while a goal is active. Text (the model's inter-tool narration and
1870
+ // the final answer) streams as standalone progress; tools/results stay in the
1871
+ // detail store only (renderLine already recorded them) to avoid flooding the
1872
+ // chat with hundreds of tool bubbles. turn_end = the goal auto-cleared and the
1873
+ // model finally stopped → leave goal mode; normal brief resumes next turn.
1874
+ const handleGoalItem = (a, item) => {
1875
+ if (item.kind === "text" || item.kind === "skill_output") {
1876
+ enqueueStandalone(a, item.body);
1877
+ return;
1878
+ }
1879
+ if (item.kind === "turn_end") {
1880
+ a.goalActive = false;
1881
+ log.info({ sessionId: a.sessionId, target: a.target }, "goal: cleared (turn ended)");
1882
+ return;
1883
+ }
1884
+ // tool_use / tool_result / user_text / turn_usage: recorded to detail store; no bubble.
1885
+ };
1886
+ // 软收口到点: 静默期内没有新 item, 确认这一轮真的结束了。
1887
+ const fireSoftTurnEnd = (a) => {
1888
+ a.softEnd = undefined;
1889
+ log.debug({ sessionId: a.sessionId, turnId: a.briefTurnId }, "soft turn_end confirmed");
1890
+ if (a.goalActive) {
1891
+ handleGoalItem(a, { kind: "turn_end" });
1892
+ return;
1893
+ }
1894
+ if (cfg.wrc.mirror.brief && a.briefTurnId) {
1895
+ closeBriefTurn(a, true);
1896
+ return;
1897
+ }
1898
+ if (a.liveStream && !a.liveStream.closed)
1899
+ void finalizeStream(a, a.liveStream);
1900
+ };
1901
+ const onItem = (a, item) => {
1902
+ // 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
1903
+ // 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
1904
+ if (a.softEnd) {
1905
+ clearTimeout(a.softEnd);
1906
+ a.softEnd = undefined;
1907
+ }
1908
+ if (item.kind === "turn_end" && item.soft === true) {
1909
+ a.softEnd = setTimeout(() => fireSoftTurnEnd(a), SOFT_TURN_END_MS);
1910
+ return;
1911
+ }
1912
+ // Record tool_use signatures unconditionally (before any state branching),
1913
+ // so flushBeforeCard's poll-drain can detect that the to-be-approved tool
1914
+ // is now persisted in the jsonl regardless of DEFERRED/STREAMING/IDLE.
1915
+ if (item.kind === "tool_use") {
1916
+ for (const c of item.calls) {
1917
+ const sig = toolUseSig(c.name, c.input);
1918
+ a.recentToolSigs.delete(sig); // re-insert at tail to keep recency order
1919
+ a.recentToolSigs.set(sig, Date.now());
1920
+ while (a.recentToolSigs.size > RECENT_SIGS_MAX) {
1921
+ const oldest = a.recentToolSigs.keys().next().value;
1922
+ if (oldest === undefined)
1923
+ break;
1924
+ a.recentToolSigs.delete(oldest);
1925
+ }
1926
+ }
1927
+ }
1928
+ // /goal mode overrides everything below: the session-scoped Stop hook
1929
+ // self-drives the model with no terminal stop_reason, so turn_end never fires
1930
+ // and brief-mode would swallow the entire run into the turn store (silent
1931
+ // WeCom for the whole goal). Enter progress-streaming on the marker; while
1932
+ // active, stream text + drop tool bubbles until the completing turn's
1933
+ // turn_end. Placed before the brief short-circuit so goal wins over brief.
1934
+ if (item.kind === "goal_start") {
1935
+ enterGoalMode(a, item.condition);
1936
+ return;
1937
+ }
1938
+ if (a.goalActive) {
1939
+ handleGoalItem(a, item);
1940
+ return;
1941
+ }
1942
+ // Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
1943
+ // 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
1944
+ // user_text 只记下来当下一轮的 query, 不开 turn (CLI 敲字 ≠ 一定有回复)。
1945
+ if (cfg.wrc.mirror.brief && !a.briefTurnId) {
1946
+ if (item.kind === "user_text")
1947
+ a.pendingBriefQuery = item.body;
1948
+ else if (BRIEF_TURN_OPENERS.has(item.kind))
1949
+ ensureBriefTurn(a);
1950
+ }
1951
+ // Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
1952
+ // 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
1953
+ // 输出的 item 类型。
1954
+ if (item.kind === "turn_usage" && !(cfg.wrc.mirror.brief && a.briefTurnId)) {
1955
+ return;
1956
+ }
1957
+ // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
1958
+ // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
1959
+ if (cfg.wrc.mirror.brief && a.briefTurnId) {
1960
+ handleBriefItem(a, item);
1961
+ return;
1962
+ }
1963
+ // 到这里已经过了 brief 分支; turn_usage 若还残留(brief=false 走上面早退,
1964
+ // brief && briefTurnId 走 handleBriefItem), 逻辑上不可能, 兜底再吞一次让 TS
1965
+ // 收窄类型 —— 下方 append/standalone 分支只处理带 body 的 item。
1966
+ if (item.kind === "turn_usage")
1967
+ return;
1968
+ // tool_result 气泡推送开关。parseLine 现在无条件发出 tool_result (供 detail/turn
1969
+ // 页消费); 非-brief 的所有气泡消费路径(deferred/awaiting/streaming/standalone)
1970
+ // 在此统一 gate: includeToolResults=false 时不把 result 推进气泡, 与旧行为一致。
1971
+ if (item.kind === "tool_result" && !cfg.wrc.mirror.includeToolResults)
1972
+ return;
1973
+ // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
1974
+ // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
1975
+ if (cfg.wrc.mirror.brief && a.briefTurnId) {
1976
+ handleBriefItem(a, item);
1977
+ return;
1978
+ }
1979
+ if (a.outbound?.kind === "deferred") {
1980
+ handleDeferredItem(a, item);
1981
+ return;
1982
+ }
1983
+ // AWAITING_APPR 兜底: needsApproval 是 mirror 这一侧的预判, hook (pre-tool-use.sh)
1984
+ // 那边在 permission_mode=auto/bypassPermissions/dontAsk 时直接放行, daemon 根本
1985
+ // 收不到 /approve, 也就永远不会有 card 点击 → onApproved 永远不触发 → AWAITING_APPR
1986
+ // 卡住, 后续 tool/text 全走 standalone fallback。这里只要看到任何新 item 到达就
1987
+ // 说明 claude 已继续执行 (hook 必然已返回 / 根本没拦), 把状态提升回 STREAMING,
1988
+ // 复用原 frame/streamId 让本轮回到打字机气泡。
1989
+ if (a.outbound?.kind === "awaiting_appr") {
1990
+ const { frame, streamId } = a.outbound;
1991
+ a.outbound = undefined;
1992
+ const s = openStream(a, frame, streamId);
1993
+ a.liveStream = s;
1994
+ log.info({ sessionId: a.sessionId, kind: item.kind, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (hook bypassed, item arrived)");
1995
+ }
1996
+ // Skill outputs (e.g. /model) always emit as standalone — never append to
1997
+ // an active stream so the result is independently visible.
1998
+ if (item.kind === "skill_output") {
1999
+ enqueueStandalone(a, item.body);
2000
+ return;
2001
+ }
2002
+ // Assistant turn truly ended (stop_reason terminal). Finalize the live
2003
+ // bubble synchronously so it becomes quotable in WeCom without waiting
2004
+ // for the next inbound or the 6-min hard timeout. No body to append.
2005
+ if (item.kind === "turn_end") {
2006
+ if (a.liveStream && !a.liveStream.closed) {
2007
+ log.debug({ sessionId: a.sessionId, turnId: a.liveStream.turnId }, "turn_end → finalize");
2008
+ void finalizeStream(a, a.liveStream);
2009
+ }
2010
+ return;
2011
+ }
2012
+ // CLI-side user line marks a turn boundary that did NOT come from WeCom.
2013
+ // Close any open WeCom liveStream first so the new conversation gets its
2014
+ // own bubble — otherwise the user's CLI exchange silently mutates the
2015
+ // previous WeCom bubble (still within its 6min update window) and is
2016
+ // invisible on the chat side.
2017
+ if (item.kind === "user_text" && a.liveStream && !a.liveStream.closed) {
2018
+ void finalizeStream(a, a.liveStream);
2019
+ }
2020
+ const s = a.liveStream;
2021
+ if (s && !s.closed && !s.dead && !s.capped) {
2022
+ // 规则 1: tool→FINAL_text 截断。仅当 text 是本 turn 的终态文本(line
2023
+ // stop_reason ∈ end_turn/stop_sequence/max_tokens)且本流已出现过 tool
2024
+ // 时, 把"最终答复"切到独立 standalone — WeCom 消息列表预览看到的就是答
2025
+ // 案而不是中间 tool 噪声。中间过程的 text(final=false)保持 append, 多
2026
+ // 轮 think→tool→think 不再被切碎。
2027
+ if (item.kind === "text" && s.sawTool && item.final === true) {
2028
+ void finalizeStream(a, s);
2029
+ enqueueStandalone(a, item.body);
2030
+ return;
2031
+ }
2032
+ const sep = s.acc ? "\n\n" : "";
2033
+ const next = s.acc + sep + item.body;
2034
+ if (next.length > STREAM_SOFT_CAP) {
2035
+ s.acc = `${s.acc}${sep}…(超出 stream 容量上限,详情见"查看详情")`;
2036
+ s.capped = true;
2037
+ }
2038
+ else {
2039
+ s.acc = next;
2040
+ }
2041
+ if (item.kind !== "text") {
2042
+ recordToolEntry(s, item);
2043
+ s.sawTool = true;
2044
+ }
2045
+ log.debug({ sessionId: a.sessionId, turnId: s.turnId, kind: item.kind, accLen: s.acc.length }, "stream append");
2046
+ scheduleFlush(s);
2047
+ return;
2048
+ }
2049
+ log.info({ sessionId: a.sessionId, kind: item.kind, hasLive: !!s, closed: s?.closed, dead: s?.dead, capped: s?.capped }, "fallback standalone");
2050
+ enqueueStandalone(a, item.body);
2051
+ };
2052
+ const resolveTarget = (override) => sanitizeId(override?.trim() || cfg.wrc.mirror.pushChat.trim() || cfg.defaultChat.trim());
2053
+ // Detach an attachment: finalize any live stream, stop the tail, drop from indexes.
2054
+ const detach = (a, reason) => {
2055
+ if (a.migrationWatcher) {
2056
+ a.migrationWatcher.cancel();
2057
+ a.migrationWatcher = undefined;
2058
+ }
2059
+ if (a.outbound?.kind === "deferred")
2060
+ clearTimeout(a.outbound.timer);
2061
+ a.outbound = undefined;
2062
+ if (a.softEnd) {
2063
+ clearTimeout(a.softEnd);
2064
+ a.softEnd = undefined;
2065
+ }
2066
+ while (a.briefTurnId)
2067
+ closeBriefTurn(a); // 活跃 + 排队中的 turn 全部收掉
2068
+ if (a.liveStream && !a.liveStream.closed)
2069
+ void finalizeStream(a, a.liveStream);
2070
+ if (a.standaloneBuf) {
2071
+ clearTimeout(a.standaloneBuf.timer);
2072
+ flushStandalone(a);
2073
+ }
2074
+ a.tail.stop();
2075
+ bySessionId.delete(a.sessionId);
2076
+ if (byTarget.get(a.target) === a)
2077
+ byTarget.delete(a.target);
2078
+ log.info({ sessionId: a.sessionId, target: a.target, reason }, "mirror detached");
2079
+ };
2080
+ // Click-to-detail URL for a tool_use id. Cached on the bridge so both
2081
+ // attach() and migrateAttachment() share the same closure. Returns ""
2082
+ // when disabled in config — renderLine then drops the markdown wrapping.
2083
+ const detailUrlFor = (id) => cfg.daemon.detailLinksInMirror && id
2084
+ ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id)
2085
+ : "";
2086
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, pendingCwd }) => {
2087
+ const target = resolveTarget(targetOverride);
2088
+ if (!target)
2089
+ return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
2090
+ // Note: jsonlPath may not exist yet on the auto-spawn path — claude only
2091
+ // creates its transcript after the first user input. The tail tolerates a
2092
+ // missing file (existsSync gate at start, try/catch in drain) and the 1s
2093
+ // poll picks it up the moment claude writes the first line.
2094
+ // Replace any existing attach with the same sessionId or same target. The
2095
+ // sessionId clash is the "/wrc again from same window" case; the target
2096
+ // clash is "different window steals my WeCom chat" — both end the previous.
2097
+ const prevBySid = bySessionId.get(sessionId);
2098
+ if (prevBySid)
2099
+ detach(prevBySid, "sessionId reattach");
2100
+ const prevByTarget = byTarget.get(target);
2101
+ // Carry over pending request only when caller didn't explicitly say
2102
+ // otherwise. `undefined` (omitted) → carry from prev (re-attach case);
2103
+ // `""` → explicit clear (newSession just consumed it); `"/foo"` → set.
2104
+ const carryPending = pendingCwd !== undefined ? pendingCwd : (prevByTarget?.pendingCwd ?? "");
2105
+ if (prevByTarget)
2106
+ detach(prevByTarget, "target reassigned");
2107
+ // Build the attachment first so the tail's onItem closure can capture it.
2108
+ const a = {
2109
+ sessionId,
2110
+ jsonlPath,
2111
+ target,
2112
+ tmuxPane: (tmuxPane ?? "").trim(),
2113
+ tmuxSession: (tmuxSession ?? "").trim(),
2114
+ // Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
2115
+ // /wrc attaches inherit the bound session's real project dir instead of
2116
+ // collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
2117
+ runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
2118
+ pendingCwd: carryPending,
2119
+ tail: { stop: () => undefined, drain: () => undefined }, // placeholder; replaced below
2120
+ standalonePending: Promise.resolve(),
2121
+ recentToolSigs: new Map(),
2122
+ };
2123
+ a.tail = startMirrorTail({
2124
+ jsonlPath,
2125
+ log: log.child({ sub: "tail", sessionId }),
2126
+ includeUser: cfg.wrc.mirror.includeUser,
2127
+ includeTools: cfg.wrc.mirror.includeTools,
2128
+ includeToolResults: cfg.wrc.mirror.includeToolResults,
2129
+ toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
2130
+ toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
2131
+ isOwnInject,
2132
+ isOwnAssistantSend,
2133
+ onItem: (item) => onItem(a, item),
2134
+ detailUrlFor,
2135
+ sessionId,
2136
+ target,
2137
+ // Dialect comes from the transcript's own root, not defaultCli — that is
2138
+ // what lets a claude session and a codebuddy session be mirrored at once.
2139
+ normalizeLine: backendForPath(jsonlPath).normalizeTranscriptLine,
2140
+ });
2141
+ bySessionId.set(sessionId, a);
2142
+ byTarget.set(target, a);
2143
+ deps.store.set(target, {
2144
+ sessionId,
2145
+ jsonlPath,
2146
+ tmuxSession: a.tmuxSession || undefined,
2147
+ tmuxPane: a.tmuxPane || undefined,
2148
+ cwd: a.runningCwd || undefined,
2149
+ pendingCwd: a.pendingCwd || undefined,
2150
+ });
2151
+ log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
2152
+ return { ok: true, sessionId, jsonlPath, target };
2153
+ };
2154
+ // ── Persistence: restore-from-store (lazy + boot) ────────────────────
2155
+ const tmuxRun = (args) => new Promise((resolve) => {
2156
+ const p = spawn("tmux", args, {
2157
+ env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
2158
+ stdio: ["ignore", "pipe", "ignore"],
2159
+ });
2160
+ let out = "";
2161
+ p.stdout?.on("data", (c) => (out += c.toString("utf8")));
2162
+ p.on("error", () => resolve({ code: null, stdout: "" }));
2163
+ p.on("close", (code) => resolve({ code, stdout: out }));
2164
+ });
2165
+ // Verify a paneId still exists. `display-message -t <paneId>` succeeds iff
2166
+ // the pane is alive — and tmux pane ids monotonically increment within a
2167
+ // server lifetime, so a freed id will not be silently reused. We don't need
2168
+ // the session name, which matters because /wrc attaches capture only $TMUX_PANE.
2169
+ const tmuxPaneAlive = async (paneId) => {
2170
+ if (!paneId)
2171
+ return false;
2172
+ const r = await tmuxRun(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
2173
+ return r.code === 0 && r.stdout.trim() === paneId;
2174
+ };
2175
+ // Re-attach a stored binding for `principal`. Returns the resulting state, or
2176
+ // undefined if the on-disk transcript is gone (in which case the entry is
2177
+ // dropped so the next inbound flows through /new auto-spawn).
2178
+ const restoreFromStore = async (principal) => {
2179
+ const rec = deps.store.get(principal);
2180
+ if (!rec)
2181
+ return undefined;
2182
+ let jsonlAbs = expandHome(rec.jsonlPath);
2183
+ if (!existsSync(jsonlAbs)) {
2184
+ // First: the SAME-sid transcript may have merely relocated to a sibling
2185
+ // project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
2186
+ // daemon was down). Re-home onto it WITHOUT changing the sessionId — the
2187
+ // tail then follows it natively. Must precede the latestJsonlForCwd heal,
2188
+ // which would otherwise grab whatever session is newest in the original
2189
+ // cwd's dir (usually a *different* chat) and cross-wire the mirror.
2190
+ const owner = backendForPath(expandHome(rec.jsonlPath));
2191
+ const relocated = findJsonlBySid(rec.sessionId, owner);
2192
+ if (relocated) {
2193
+ log.info({ principal, sessionId: rec.sessionId, from: rec.jsonlPath, to: relocated }, "mirror restore: sid relocated (worktree), re-homed");
2194
+ rec.jsonlPath = relocated;
2195
+ jsonlAbs = relocated;
2196
+ deps.store.set(principal, rec);
2197
+ }
2198
+ else {
2199
+ // Recorded session rotated out from under us — a `/clear` or native `/new`
2200
+ // the daemon didn't record (e.g. it restarted while the /clear migration
2201
+ // watcher was still open). The rotation left a newer jsonl in the same
2202
+ // project dir; heal onto it instead of dropping the binding, else the
2203
+ // chat silently loses all further responses. Fail-closed drop only when
2204
+ // the project dir is truly empty.
2205
+ const healed = latestJsonlForCwd(rec.cwd || cfg.wrc.cwd, owner);
2206
+ if (!healed) {
2207
+ log.warn({ principal, jsonlPath: rec.jsonlPath }, "mirror restore: jsonl missing and no sibling, dropping entry");
2208
+ deps.store.drop(principal);
2209
+ return undefined;
2210
+ }
2211
+ log.warn({ principal, from: rec.sessionId, to: healed.sessionId }, "mirror restore: recorded jsonl gone, healed to latest in project dir");
2212
+ rec.sessionId = healed.sessionId;
2213
+ rec.jsonlPath = healed.jsonlPath;
2214
+ jsonlAbs = healed.jsonlPath;
2215
+ deps.store.set(principal, rec);
2216
+ }
2217
+ }
2218
+ // Prefer the stored pane id and validate via display-message. Pane ids
2219
+ // (`%N`) are monotonic per tmux server lifetime, so they're stable across
2220
+ // daemon reloads as long as the tmux server didn't restart. With the
2221
+ // shared `weclaude` session hosting many chats, listing panes by session
2222
+ // name would mis-route to whichever window happens to be first — never
2223
+ // do that. If the stored pane is dead, leave tmuxPane empty and let
2224
+ // dispatch's respawn check reincarnate via `claude --resume <sid>`.
2225
+ const storedPane = (rec.tmuxPane ?? "").trim();
2226
+ const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
2227
+ // Pane-cwd worktree re-home: the stored jsonl can EXIST yet be stale because
2228
+ // the live pane entered/selected a git worktree, switching it to a DIFFERENT
2229
+ // sessionId in a sibling project dir. Trust the pane's real cwd — if its
2230
+ // project dir differs and holds a live session under another sid, bind that.
2231
+ // Without this, two chats whose panes diverged into worktrees but still
2232
+ // carry the old shared sid collide in bySessionId (attach → replace) and one
2233
+ // is silently detached; that detached chat then never mirrors again. Runs at
2234
+ // boot; the 3s drift follower maintains it thereafter.
2235
+ if (livePane) {
2236
+ const cwdRes = await tmuxRun(["display-message", "-p", "-t", livePane, "#{pane_current_path}"]);
2237
+ const paneCwd = cwdRes.stdout.trim();
2238
+ if (paneCwd) {
2239
+ // Encode under the backend that owns the bound transcript — comparing
2240
+ // with the primary dialect would report a phantom drift for every
2241
+ // session belonging to a non-primary CLI.
2242
+ const paneDir = projectDirFor(jsonlAbs, expandHome(paneCwd));
2243
+ if (paneDir !== dirname(jsonlAbs)) {
2244
+ const live = liveSessionForCwd(paneCwd, backendForPath(jsonlAbs));
2245
+ if (live && live.sessionId !== rec.sessionId) {
2246
+ log.info({ principal, from: rec.sessionId, to: live.sessionId, paneCwd }, "mirror restore: pane in worktree, re-homed to live session");
2247
+ rec.sessionId = live.sessionId;
2248
+ rec.jsonlPath = live.jsonlPath;
2249
+ jsonlAbs = live.jsonlPath;
2250
+ rec.cwd = expandHome(paneCwd);
2251
+ deps.store.set(principal, rec);
2252
+ }
2253
+ }
2254
+ }
2255
+ }
2256
+ const r = attach({
2257
+ sessionId: rec.sessionId,
2258
+ jsonlPath: jsonlAbs,
2259
+ target: principal,
2260
+ tmuxPane: livePane,
2261
+ tmuxSession: rec.tmuxSession ?? "",
2262
+ cwd: rec.cwd,
2263
+ pendingCwd: rec.pendingCwd,
2264
+ });
2265
+ if (!r.ok) {
2266
+ log.warn({ principal, reason: r.reason }, "mirror restore: re-attach failed");
2267
+ return undefined;
2268
+ }
2269
+ log.info({ principal, sessionId: rec.sessionId, livePane: livePane || "(spawn-mode)" }, "mirror restored from store");
2270
+ return byTarget.get(principal);
2271
+ };
2272
+ // We re-attach lazily on demand too (see restoreFromStore in dispatch /
2273
+ // hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
2274
+ // even before any inbound arrives — e.g. claude finishes a long-running
2275
+ // background task and writes assistant content to the jsonl while idle.
2276
+ const persisted = deps.store.all();
2277
+ const persistedKeys = Object.keys(persisted);
2278
+ if (persistedKeys.length > 0) {
2279
+ for (const principal of persistedKeys) {
2280
+ void restoreFromStore(principal);
2281
+ }
2282
+ }
2283
+ else if (cfg.wrc.mirror.sessionId.trim()) {
2284
+ // Pinned-sessionId fallback: only honor when the store is empty (otherwise
2285
+ // the persisted bindings already cover the right sessions).
2286
+ const resolved = resolveSession(cfg, log);
2287
+ if (resolved) {
2288
+ const r = attach({ sessionId: resolved.sessionId, jsonlPath: resolved.jsonlPath });
2289
+ if (!r.ok)
2290
+ log.warn({ reason: r.reason }, "mirror: pinned auto-attach skipped");
2291
+ }
2292
+ }
2293
+ else {
2294
+ log.info("mirror: no persisted attachments and no pinned sessionId — waiting for /new or /mirror/attach");
2295
+ }
2296
+ // Render full tool details for a finalized turn into one-or-more markdown
2297
+ // chunks (split at chunkChars boundaries).
2298
+ const renderToolDetails = (tools) => {
2299
+ const parts = [];
2300
+ let i = 0;
2301
+ for (const t of tools) {
2302
+ i += 1;
2303
+ const inputJson = t.input === undefined
2304
+ ? "(no input)"
2305
+ : (() => {
2306
+ try {
2307
+ return JSON.stringify(t.input, null, 2);
2308
+ }
2309
+ catch {
2310
+ return "(unrenderable)";
2311
+ }
2312
+ })();
2313
+ const result = t.result ?? "(no result captured)";
2314
+ parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncate(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncate(result, 4000)}\n\`\`\``);
2315
+ }
2316
+ const merged = parts.join("\n\n---\n\n");
2317
+ return splitChunks(merged, Math.max(200, cfg.wrc.mirror.chunkChars - TAG_HEADER_BUDGET));
2318
+ };
2319
+ const resolveToolDetail = (turnId) => {
2320
+ evictTurns();
2321
+ const r = turnRegistry.get(turnId);
2322
+ if (!r)
2323
+ return undefined;
2324
+ return { target: r.target, markdown: renderToolDetails(r.tools) };
2325
+ };
2326
+ // ── /clear → session migration ──────────────────────────────────────
2327
+ // After `/clear` claude rotates to a fresh sessionId on the very next user
2328
+ // input. Detect the rotation by watching the project dir for a new .jsonl
2329
+ // that didn't exist at /clear time and contains a non-empty user line, then
2330
+ // re-target this attachment onto it.
2331
+ const isClearCommand = (text) => {
2332
+ const t = text.trim();
2333
+ return t === "/clear" || t.startsWith("/clear ") || t.startsWith("/clear\n");
2334
+ };
2335
+ const listJsonls = (dir) => {
2336
+ try {
2337
+ return new Set(readdirSync(dir).filter((n) => n.endsWith(".jsonl")));
2338
+ }
2339
+ catch {
2340
+ return new Set();
2341
+ }
2342
+ };
2343
+ // Post-/clear identification: claude rotates the session IMMEDIATELY on
2344
+ // /clear (not on next user input as the original design assumed) and writes
2345
+ // the `/clear` command itself as the first non-meta user line of the brand-
2346
+ // new jsonl. Match that signature to (a) confirm the file is a /clear-rotated
2347
+ // child, (b) avoid mis-migrating onto an unrelated jsonl that some other
2348
+ // claude process happened to create on the same cwd in our window.
2349
+ const SLASH_CLEAR_USER_RE = /<command-name>\s*\/clear\s*<\/command-name>/;
2350
+ const jsonlIsPostClearChild = (path) => {
2351
+ try {
2352
+ const fd = openSync(path, "r");
2353
+ const size = statSync(path).size;
2354
+ const cap = Math.min(size, 64 * 1024);
2355
+ const buf = Buffer.alloc(cap);
2356
+ readSync(fd, buf, 0, cap, 0);
2357
+ closeSync(fd);
2358
+ const text = buf.toString("utf8");
2359
+ for (const line of text.split("\n")) {
2360
+ if (!line.trim())
2361
+ continue;
2362
+ try {
2363
+ const j = normalizeForPath(path, JSON.parse(line));
2364
+ if (!j || j.type !== "user" || j.isMeta || j.isSidechain)
2365
+ continue;
2366
+ const c = j.message?.content;
2367
+ // codebuddy writes the caveat + /clear + stdout as sibling plain user
2368
+ // messages — skip the noise so the DECIDING line is the first real
2369
+ // one, same as Claude's isMeta-filtered stream.
2370
+ if (isLocalCommandNoise(c))
2371
+ continue;
2372
+ // First non-meta user line decides: /clear → match; anything else → reject.
2373
+ return typeof c === "string" && SLASH_CLEAR_USER_RE.test(c);
2374
+ }
2375
+ catch { /* partial line */ }
2376
+ }
2377
+ }
2378
+ catch { /* unreadable */ }
2379
+ return false;
2380
+ };
2381
+ // First non-meta user-line uuid of a transcript. A freshly resume-forked
2382
+ // child (`claude --resume <sid>` interactive) is seeded with a copy of the
2383
+ // parent transcript, so it carries a real user line the instant it appears —
2384
+ // this distinguishes it from an empty just-touched jsonl. (We can't reuse the
2385
+ // /clear signature: a resume fork's first user line is the actual prompt, not
2386
+ // `/clear`.) Returns undefined for empty/garbled files.
2387
+ const firstUserUuid = (path) => {
2388
+ try {
2389
+ const fd = openSync(path, "r");
2390
+ const size = statSync(path).size;
2391
+ const cap = Math.min(size, 256 * 1024);
2392
+ const buf = Buffer.alloc(cap);
2393
+ readSync(fd, buf, 0, cap, 0);
2394
+ closeSync(fd);
2395
+ for (const line of buf.toString("utf8").split("\n")) {
2396
+ if (!line.trim())
2397
+ continue;
2398
+ try {
2399
+ const j = normalizeForPath(path, JSON.parse(line));
2400
+ if (j?.type === "user" && !j.isMeta && !j.isSidechain && !isLocalCommandNoise(j.message?.content) && typeof j.uuid === "string")
2401
+ return j.uuid;
2402
+ }
2403
+ catch { /* partial line */ }
2404
+ }
2405
+ }
2406
+ catch { /* unreadable */ }
2407
+ return undefined;
2408
+ };
2409
+ // startOffset semantics differ by caller: /clear passes 0 to replay the
2410
+ // freshly-rotated jsonl (only holds the /clear line + new content); resume
2411
+ // migration omits it (→ tail from EOF) because the fork file is seeded with
2412
+ // the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
2413
+ const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
2414
+ const oldSessionId = a.sessionId;
2415
+ const oldJsonlPath = a.jsonlPath;
2416
+ a.tail.stop();
2417
+ bySessionId.delete(oldSessionId);
2418
+ a.sessionId = newSessionId;
2419
+ a.jsonlPath = newJsonlPath;
2420
+ bySessionId.set(newSessionId, a);
2421
+ a.tail = startMirrorTail({
2422
+ jsonlPath: newJsonlPath,
2423
+ log: log.child({ sub: "tail", sessionId: newSessionId }),
2424
+ includeUser: cfg.wrc.mirror.includeUser,
2425
+ includeTools: cfg.wrc.mirror.includeTools,
2426
+ includeToolResults: cfg.wrc.mirror.includeToolResults,
2427
+ toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
2428
+ toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
2429
+ isOwnInject,
2430
+ isOwnAssistantSend,
2431
+ onItem: (item) => onItem(a, item),
2432
+ detailUrlFor,
2433
+ sessionId: newSessionId,
2434
+ target: a.target,
2435
+ startOffset,
2436
+ // A migration can cross backends (rare, but the new jsonl is resolved by
2437
+ // path, not by CLI) — re-derive the dialect from the destination.
2438
+ normalizeLine: backendForPath(newJsonlPath).normalizeTranscriptLine,
2439
+ });
2440
+ deps.store.set(a.target, {
2441
+ sessionId: newSessionId,
2442
+ jsonlPath: newJsonlPath,
2443
+ tmuxSession: a.tmuxSession || undefined,
2444
+ tmuxPane: a.tmuxPane || undefined,
2445
+ cwd: a.runningCwd || undefined,
2446
+ pendingCwd: a.pendingCwd || undefined,
2447
+ });
2448
+ log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
2449
+ };
2450
+ // Watch the project dir for a new jsonl (not in `baseline`) that satisfies
2451
+ // `isChild`, then re-bind the attachment onto it. Used by both /clear
2452
+ // (predicate = first user line is /clear; replay from 0) and dead-pane resume
2453
+ // (predicate = file has real user content; tail from EOF — the fork is seeded
2454
+ // with full history). `startOffset` flows through to migrateAttachment.
2455
+ const startMigrationWatcher = (a, baseline, isChild, startOffset) => {
2456
+ if (a.migrationWatcher)
2457
+ a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
2458
+ const projectDir = dirname(a.jsonlPath);
2459
+ // baseline is captured by the caller BEFORE inject/respawn runs — claude
2460
+ // creates the rotated/forked jsonl while processing it, so a baseline taken
2461
+ // here (post-inject) would already include it and migration would never fire.
2462
+ const POLL_MS = 500;
2463
+ const TIMEOUT_MS = 5 * 60_000; // generous: user may take a while to type
2464
+ const t0 = Date.now();
2465
+ let stopped = false;
2466
+ let timer;
2467
+ const tick = () => {
2468
+ if (stopped)
2469
+ return;
2470
+ if (Date.now() - t0 > TIMEOUT_MS) {
2471
+ log.warn({ target: a.target, sessionId: a.sessionId }, "mirror migration: timeout, giving up");
2472
+ a.migrationWatcher = undefined;
2473
+ return;
2474
+ }
2475
+ const current = listJsonls(projectDir);
2476
+ const candidates = [];
2477
+ for (const name of current)
2478
+ if (!baseline.has(name))
2479
+ candidates.push(name);
2480
+ // Pick newest-mtime candidate that has user content. Older candidates
2481
+ // without content stay in the running until they accrue — never aborts.
2482
+ const ranked = candidates
2483
+ .map((n) => ({ n, mtime: (() => { try {
2484
+ return statSync(join(projectDir, n)).mtimeMs;
2485
+ }
2486
+ catch {
2487
+ return 0;
2488
+ } })() }))
2489
+ .sort((x, y) => y.mtime - x.mtime);
2490
+ for (const c of ranked) {
2491
+ const p = join(projectDir, c.n);
2492
+ if (isChild(p)) {
2493
+ stopped = true;
2494
+ a.migrationWatcher = undefined;
2495
+ const newSid = c.n.replace(/\.jsonl$/, "");
2496
+ if (newSid !== a.sessionId)
2497
+ migrateAttachment(a, newSid, p, startOffset);
2498
+ return;
2499
+ }
2500
+ }
2501
+ timer = setTimeout(tick, POLL_MS);
2502
+ };
2503
+ a.migrationWatcher = {
2504
+ cancel: () => { stopped = true; if (timer)
2505
+ clearTimeout(timer); },
2506
+ };
2507
+ log.info({ target: a.target, sessionId: a.sessionId, projectDir, baselineCount: baseline.size }, "mirror migration: watcher armed");
2508
+ timer = setTimeout(tick, POLL_MS);
2509
+ };
2510
+ // A user who runs /clear (or restarts claude) directly in the live TUI forks
2511
+ // the pane onto a NEW sid in the SAME project dir — invisible to both watchers
2512
+ // above (startMigrationWatcher wants a daemon-injected new file; followPaneDrift
2513
+ // wants a cross-dir move). The tail then sits on the dead old jsonl and the "…"
2514
+ // bubble never updates. After every inject, if the bound jsonl stays silent
2515
+ // while a same-dir sibling swallowed our exact injected text, rebind onto it —
2516
+ // replaying from our message so the response is mirrored.
2517
+ //
2518
+ // The rebind is only safe because the fork lives under THIS chat's own pane.
2519
+ // Three guards keep it from mis-migrating across the many sessions that
2520
+ // legitimately share one project dir (the classic "串session"):
2521
+ // 1. pane liveness — a dead pane means the fork premise is void (the
2522
+ // dead-pane respawn path owns that); never cross-rebind then.
2523
+ // 2. pane cwd — the pane must still sit in the bound project dir; if it
2524
+ // drifted elsewhere, followPaneDrift is the right handler.
2525
+ // 3. ownership — never adopt a sid another live mirror is already tailing;
2526
+ // a fingerprint hit on a concurrently-busy sibling is a false positive,
2527
+ // not our fork. A genuine TUI fork is a brand-new, unowned session.
2528
+ // The fingerprint itself is lengthened (and its min-length raised) so short /
2529
+ // common messages can't collide onto a stranger's transcript in the first place.
2530
+ const armSilentForkRebind = (a, text) => {
2531
+ if (a.migrationWatcher)
2532
+ return; // don't race an in-flight watcher
2533
+ if (!a.tmuxPane)
2534
+ return; // spawn-mode: no pane to fork under
2535
+ const stripped = text.replace(/\s+/gu, "");
2536
+ if (stripped.length < 16)
2537
+ return; // too short to fingerprint safely
2538
+ const fp = stripped.slice(0, 120); // long contiguous run → collision-resistant
2539
+ const dir = dirname(a.jsonlPath);
2540
+ const boundPath = a.jsonlPath;
2541
+ let baseSize = 0;
2542
+ try {
2543
+ baseSize = statSync(boundPath).size;
2544
+ }
2545
+ catch { /* fresh */ }
2546
+ const POLL_MS = 700;
2547
+ const TIMEOUT_MS = 25_000;
2548
+ const t0 = Date.now();
2549
+ let stopped = false;
2550
+ let timer;
2551
+ const reschedule = () => { timer = setTimeout(() => void tick(), POLL_MS); };
2552
+ const tick = async () => {
2553
+ if (stopped)
2554
+ return;
2555
+ if (Date.now() - t0 > TIMEOUT_MS) {
2556
+ a.migrationWatcher = undefined;
2557
+ return;
2558
+ }
2559
+ // Bound jsonl grew → our message landed in the right session; nothing to do.
2560
+ try {
2561
+ if (statSync(boundPath).size > baseSize) {
2562
+ a.migrationWatcher = undefined;
2563
+ return;
2564
+ }
2565
+ }
2566
+ catch { /* */ }
2567
+ // Guard 1+2: pane must be alive AND still in the bound project dir. One
2568
+ // display-message gives both — it fails on a dead pane, and its cwd tells
2569
+ // us whether the pane still belongs here.
2570
+ const r = await tmuxRun(["display-message", "-p", "-t", a.tmuxPane, "#{pane_current_path}"]);
2571
+ if (stopped)
2572
+ return;
2573
+ const paneCwd = r.code === 0 ? r.stdout.trim() : "";
2574
+ if (!paneCwd) {
2575
+ a.migrationWatcher = undefined;
2576
+ return;
2577
+ } // pane gone → dead-pane path owns it
2578
+ const paneDir = projectDirFor(boundPath, expandHome(paneCwd));
2579
+ if (paneDir !== dir) {
2580
+ reschedule();
2581
+ return;
2582
+ } // pane drifted → followPaneDrift owns it
2583
+ let names = [];
2584
+ try {
2585
+ names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
2586
+ }
2587
+ catch { /* */ }
2588
+ const boundName = boundPath.slice(dir.length + 1);
2589
+ const ranked = names
2590
+ .filter((n) => n !== boundName)
2591
+ .map((n) => ({ n, m: (() => { try {
2592
+ return statSync(join(dir, n)).mtimeMs;
2593
+ }
2594
+ catch {
2595
+ return 0;
2596
+ } })() }))
2597
+ .sort((x, y) => y.m - x.m);
2598
+ for (const c of ranked) {
2599
+ const off = findInjectOffset(join(dir, c.n), fp);
2600
+ if (off === undefined)
2601
+ continue;
2602
+ const newSid = c.n.replace(/\.jsonl$/, "");
2603
+ // Guard 3: a sid another live mirror already tails is that chat's
2604
+ // session, not our fork — a fingerprint false positive. Skip it and
2605
+ // keep scanning; a real fork is unowned.
2606
+ const owner = bySessionId.get(newSid);
2607
+ if (owner && owner !== a) {
2608
+ log.warn({ target: a.target, newSid, ownerTarget: owner.target }, "mirror: skip fork rebind — session owned by another mirror (fingerprint false positive)");
2609
+ continue;
2610
+ }
2611
+ stopped = true;
2612
+ a.migrationWatcher = undefined;
2613
+ if (newSid !== a.sessionId) {
2614
+ log.info({ target: a.target, oldSid: a.sessionId, newSid, off }, "mirror: silent same-dir fork, rebinding onto forked session");
2615
+ migrateAttachment(a, newSid, join(dir, c.n), off);
2616
+ }
2617
+ return;
2618
+ }
2619
+ reschedule();
2620
+ };
2621
+ a.migrationWatcher = { cancel: () => { stopped = true; if (timer)
2622
+ clearTimeout(timer); } };
2623
+ reschedule();
2624
+ };
2625
+ // ── Worktree / session drift follow ─────────────────────────────────
2626
+ // An inject isn't the only way the live session under a pane changes:
2627
+ // entering OR selecting a git worktree makes Claude Code switch the pane to a
2628
+ // DIFFERENT sessionId in a sibling project dir (the worktree cwd encodes to
2629
+ // its own dir). That's neither a same-sid rename (findJsonlBySid follows those
2630
+ // with a continuous offset) nor an inject-triggered rotation (startMigration-
2631
+ // Watcher catches those) — the old jsonl just goes quiet and the mirror tails
2632
+ // a dead file. Poll each attached pane's real cwd; when it points at a
2633
+ // different project dir whose live session has a different sid, migrate onto
2634
+ // it. Tail from EOF (undefined startOffset): the worktree session already
2635
+ // carries full history we don't want to re-dump to WeCom.
2636
+ let paneDriftTicking = false;
2637
+ const followPaneDrift = async () => {
2638
+ if (paneDriftTicking)
2639
+ return; // skip overlapping ticks (tmux call is async)
2640
+ paneDriftTicking = true;
2641
+ try {
2642
+ const attachments = Array.from(byTarget.values()).filter((a) => a.tmuxPane);
2643
+ if (attachments.length === 0)
2644
+ return;
2645
+ const r = await tmuxRun(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
2646
+ if (r.code !== 0)
2647
+ return;
2648
+ const paneCwd = new Map();
2649
+ for (const line of r.stdout.split("\n")) {
2650
+ const tab = line.indexOf("\t");
2651
+ if (tab !== -1)
2652
+ paneCwd.set(line.slice(0, tab).trim(), line.slice(tab + 1).trim());
2653
+ }
2654
+ for (const a of attachments) {
2655
+ if (a.migrationWatcher)
2656
+ continue; // inject-driven migration in flight
2657
+ if (a.liveStream && !a.liveStream.closed)
2658
+ continue; // mid typewriter — don't yank the tail
2659
+ const cwd = paneCwd.get(a.tmuxPane);
2660
+ if (!cwd)
2661
+ continue; // pane gone
2662
+ const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
2663
+ if (paneDir === dirname(a.jsonlPath))
2664
+ continue; // same project dir — no drift
2665
+ const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath));
2666
+ if (!live || live.sessionId === a.sessionId)
2667
+ continue; // empty dir, or same-sid rename (tail follows it)
2668
+ a.runningCwd = expandHome(cwd); // migrateAttachment persists this to store
2669
+ log.info({ target: a.target, pane: a.tmuxPane, fromDir: dirname(a.jsonlPath), toDir: paneDir, oldSid: a.sessionId, newSid: live.sessionId }, "mirror: pane drifted (worktree), following live session");
2670
+ migrateAttachment(a, live.sessionId, live.jsonlPath);
2671
+ }
2672
+ }
2673
+ catch (e) {
2674
+ log.warn({ err: e.message }, "pane-drift follow tick failed");
2675
+ }
2676
+ finally {
2677
+ paneDriftTicking = false;
2678
+ }
2679
+ };
2680
+ const paneDriftTimer = setInterval(() => void followPaneDrift(), 3000);
2681
+ // ── Cwd lifecycle ───────────────────────────────────────────────────
2682
+ // Per-chat project path. Stored on the attachment (live) and persisted to
2683
+ // mirror-attachments.json so /pwd survives reload. `pendingCwd` is the
2684
+ // user-requested next cwd — applied only on /new (or /clear when it differs
2685
+ // from the running cwd). Decoupling means /pwd can show truth even after
2686
+ // the AI sets a new path but the user hasn't /new'd yet.
2687
+ const expandedDefaultCwd = expandHome(cfg.wrc.cwd);
2688
+ const renderProjectInfo = (target) => {
2689
+ const a = byTarget.get(target);
2690
+ const rec = a ? undefined : deps.store.get(target);
2691
+ const running = (a?.runningCwd?.trim()) || rec?.cwd?.trim() || expandedDefaultCwd;
2692
+ // pendingCwd is chat-scoped — the queued switch applies to every session
2693
+ // in this chat, so read it from the shared base slot rather than the
2694
+ // caller's own (now-empty) attachment record.
2695
+ const pending = chatCwdFallback(target).pending;
2696
+ const lines = [`📂 当前项目: \`${running}\``];
2697
+ if (pending && pending !== running) {
2698
+ lines.push(`下次切换: \`${pending}\` (使用 /new 或 /clear 生效)`);
2699
+ }
2700
+ lines.push("> 切换其他项目: 在对话中告诉 AI 调用 `cd` MCP 工具");
2701
+ // Session-boundary footer — `/new` and `/clear` are the only two callers,
2702
+ // so the tip lands exactly once per fresh context, never mid-conversation.
2703
+ lines.push(randomTip());
2704
+ return lines.join("\n");
2705
+ };
2706
+ const pushProjectInfo = (target) => {
2707
+ const md = renderProjectInfo(target);
2708
+ const a = byTarget.get(target);
2709
+ if (a) {
2710
+ sendStandalone(a, md);
2711
+ return;
2712
+ }
2713
+ // No attachment (rare — newSession always re-attaches before pushing).
2714
+ // Send via plain sendMessage so the user still gets the info.
2715
+ const chatId = stripPrincipalPrefix(target);
2716
+ void client
2717
+ .sendMessage(chatId, { msgtype: "markdown", markdown: { content: withSessionTag(target, md) } })
2718
+ .catch((e) => log.warn({ err: e.message, target }, "pushProjectInfo (no attach) failed"));
2719
+ };
2720
+ // Cwd is CHAT-SCOPED, not session-scoped: all sessions in the same chat
2721
+ // (default + any `#tag` siblings) share one cwd/pendingCwd, tracked on the
2722
+ // BASE principal's byTarget/store record. Tagged sessions still have their
2723
+ // own `runningCwd` (the tmux pane's actual working dir at spawn time), but
2724
+ // cwd fallbacks and `cd` pendingCwd writes always resolve against the base.
2725
+ const chatCwdFallback = (target) => {
2726
+ const base = basePrincipalOf(target);
2727
+ const baseA = byTarget.get(base);
2728
+ const baseRec = deps.store.get(base);
2729
+ return {
2730
+ pending: (baseA?.pendingCwd?.trim()) || (baseRec?.pendingCwd?.trim()) || "",
2731
+ running: (baseA?.runningCwd?.trim()) || (baseRec?.cwd?.trim()) || "",
2732
+ };
2733
+ };
2734
+ // /new path: kill the old pane (so we don't leak orphan tmux windows) and
2735
+ // spawn a fresh claude in pendingCwd ?? runningCwd ?? default. Returns the
2736
+ // new sessionId/cwd so callers can render the user-facing reply.
2737
+ const newSession = async (target, windowName, cli, opts) => {
2738
+ const prev = byTarget.get(target);
2739
+ // Resolution precedence (all chat-scoped except the running-cwd fallback):
2740
+ // base.pending > target.running > base.running > default
2741
+ // A fresh tagged session inherits the chat's current cwd; re-`/new`ing a
2742
+ // live tagged session keeps its pane cwd unless the base session queued a
2743
+ // `cd`. This keeps siblings aligned by default without forcibly clobbering
2744
+ // an already-spawned tagged pane on every base-cwd change.
2745
+ const chat = chatCwdFallback(target);
2746
+ const rec = !prev ? deps.store.get(target) : undefined;
2747
+ // An explicit per-node cwd (graph spec) outranks every chat-scoped fallback:
2748
+ // the point of declaring it is that this node lives in a different repo.
2749
+ const eff = (opts?.cwd?.trim() ? expandHome(opts.cwd.trim()) : "") ||
2750
+ chat.pending ||
2751
+ (prev?.runningCwd?.trim()) ||
2752
+ (rec?.cwd?.trim()) ||
2753
+ chat.running ||
2754
+ expandedDefaultCwd;
2755
+ // Inherit the outgoing session's CLI when the caller didn't name one: a
2756
+ // `/new` (or a /clear upgraded to /new) on a codebuddy-bound chat must stay
2757
+ // on codebuddy rather than silently reverting to `defaultCli`. Inheritance
2758
+ // is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
2759
+ // so it falls back to the base session's binding instead of `defaultCli`
2760
+ // (otherwise a tagged sibling silently forks onto a different CLI).
2761
+ const base = basePrincipalOf(target);
2762
+ const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
2763
+ const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
2764
+ const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
2765
+ if (prev?.tmuxPane) {
2766
+ // Best-effort kill; ignore errors (pane may already be dead).
2767
+ void tmuxRun(["kill-pane", "-t", prev.tmuxPane]);
2768
+ }
2769
+ if (prev)
2770
+ detach(prev, "/new respawn");
2771
+ const r = await spawnTmuxClaude({
2772
+ cfg,
2773
+ log: log.child({ sub: "new-session", target }),
2774
+ windowName: windowName ?? target,
2775
+ cwdOverride: eff,
2776
+ cli: effCli,
2777
+ model: opts?.model,
2778
+ });
2779
+ if (!r.ok)
2780
+ return { ok: false, reason: r.reason };
2781
+ const att = attach({
2782
+ sessionId: r.sessionId,
2783
+ jsonlPath: r.jsonlPath,
2784
+ target,
2785
+ tmuxPane: r.tmuxPane,
2786
+ tmuxSession: r.tmuxSession,
2787
+ cwd: r.cwd,
2788
+ // Explicit "" clears any carried-over pending — it has just been applied.
2789
+ pendingCwd: "",
2790
+ });
2791
+ if (!att.ok)
2792
+ return { ok: false, reason: att.reason };
2793
+ // 首条注入吃冷时序(injectText 走 /mirror/spawn 时已经硬编码 freshSpawn:true,
2794
+ // dispatch 这条隐式建会话的路径此前漏了)。
2795
+ const spawned = byTarget.get(target);
2796
+ if (spawned)
2797
+ spawned.justSpawned = true;
2798
+ // Clear the chat's pendingCwd on the BASE record too — the queued switch
2799
+ // has just been consumed by this respawn. Without this, a subsequent /new
2800
+ // #other would re-apply the same cd and diverge from user intent.
2801
+ if (base !== target) {
2802
+ const baseA = byTarget.get(base);
2803
+ if (baseA?.pendingCwd) {
2804
+ baseA.pendingCwd = "";
2805
+ deps.store.set(base, {
2806
+ sessionId: baseA.sessionId,
2807
+ jsonlPath: baseA.jsonlPath,
2808
+ tmuxSession: baseA.tmuxSession || undefined,
2809
+ tmuxPane: baseA.tmuxPane || undefined,
2810
+ cwd: baseA.runningCwd || undefined,
2811
+ pendingCwd: undefined,
2812
+ });
2813
+ }
2814
+ else {
2815
+ const baseRec = deps.store.get(base);
2816
+ if (baseRec?.pendingCwd)
2817
+ deps.store.set(base, { ...baseRec, pendingCwd: undefined });
2818
+ }
2819
+ }
2820
+ pushProjectInfo(target);
2821
+ return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
2822
+ };
2823
+ const getCwd = (target) => {
2824
+ // pendingCwd is chat-scoped — a `cd` from any sibling session queues the
2825
+ // switch for the whole chat. runningCwd stays per-session (each pane has
2826
+ // its own spawn dir).
2827
+ const chat = chatCwdFallback(target);
2828
+ const pending = chat.pending;
2829
+ const a = byTarget.get(target);
2830
+ if (a)
2831
+ return { runningCwd: a.runningCwd || expandedDefaultCwd, pendingCwd: pending, defaultCwd: expandedDefaultCwd };
2832
+ const rec = deps.store.get(target);
2833
+ if (rec)
2834
+ return { runningCwd: rec.cwd?.trim() || expandedDefaultCwd, pendingCwd: pending, defaultCwd: expandedDefaultCwd };
2835
+ return { runningCwd: chat.running || expandedDefaultCwd, pendingCwd: pending, defaultCwd: expandedDefaultCwd };
2836
+ };
2837
+ // Write `pendingCwd` to the BASE principal so the switch applies chat-wide —
2838
+ // the next /new in any tagged/untagged session picks it up. `cd` from a
2839
+ // tagged session still writes to the shared slot, not the tagged session's
2840
+ // own record, matching "sessions share the chat's cwd".
2841
+ const setPendingCwd = (target, cwd) => {
2842
+ const trimmed = (cwd ?? "").trim();
2843
+ if (!trimmed)
2844
+ return { ok: false, reason: "empty cwd", runningCwd: "", pendingCwd: "" };
2845
+ const expanded = expandHome(trimmed);
2846
+ if (!expanded.startsWith("/"))
2847
+ return { ok: false, reason: "cwd must be absolute (or start with ~)", runningCwd: "", pendingCwd: "" };
2848
+ const base = basePrincipalOf(target);
2849
+ const callerA = byTarget.get(target);
2850
+ const callerRunning = callerA?.runningCwd?.trim() || deps.store.get(target)?.cwd?.trim() || expandedDefaultCwd;
2851
+ const baseA = byTarget.get(base);
2852
+ if (baseA) {
2853
+ baseA.pendingCwd = expanded;
2854
+ deps.store.set(base, {
2855
+ sessionId: baseA.sessionId,
2856
+ jsonlPath: baseA.jsonlPath,
2857
+ tmuxSession: baseA.tmuxSession || undefined,
2858
+ tmuxPane: baseA.tmuxPane || undefined,
2859
+ cwd: baseA.runningCwd || undefined,
2860
+ pendingCwd: baseA.pendingCwd || undefined,
2861
+ });
2862
+ log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, live base)");
2863
+ return { ok: true, runningCwd: callerRunning, pendingCwd: expanded };
2864
+ }
2865
+ const baseRec = deps.store.get(base);
2866
+ if (baseRec) {
2867
+ deps.store.set(base, { ...baseRec, pendingCwd: expanded });
2868
+ log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, persisted base)");
2869
+ return { ok: true, runningCwd: callerRunning, pendingCwd: expanded };
2870
+ }
2871
+ // No base binding yet (caller is a tagged session created before any
2872
+ // default session existed). Fall back to writing on the caller's own
2873
+ // record so the pending switch isn't lost — the next default /new will
2874
+ // then inherit via chatCwdFallback and normalize onto the base.
2875
+ if (callerA) {
2876
+ callerA.pendingCwd = expanded;
2877
+ deps.store.set(target, {
2878
+ sessionId: callerA.sessionId,
2879
+ jsonlPath: callerA.jsonlPath,
2880
+ tmuxSession: callerA.tmuxSession || undefined,
2881
+ tmuxPane: callerA.tmuxPane || undefined,
2882
+ cwd: callerA.runningCwd || undefined,
2883
+ pendingCwd: callerA.pendingCwd || undefined,
2884
+ });
2885
+ log.info({ target, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd }, "setPendingCwd (fallback: no base, wrote to caller)");
2886
+ return { ok: true, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd };
2887
+ }
2888
+ const callerRec = deps.store.get(target);
2889
+ if (callerRec) {
2890
+ deps.store.set(target, { ...callerRec, pendingCwd: expanded });
2891
+ log.info({ target, runningCwd: callerRec.cwd, pendingCwd: expanded }, "setPendingCwd (fallback: no base, wrote to caller persist)");
2892
+ return { ok: true, runningCwd: callerRec.cwd?.trim() || expandedDefaultCwd, pendingCwd: expanded };
2893
+ }
2894
+ return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", runningCwd: "", pendingCwd: "" };
2895
+ };
2896
+ // ── Peer graph (sibling sessions of one chat) ────────────────────────
2897
+ // A chat's sessions are exactly the keys sharing its base principal: the
2898
+ // untagged default plus every `#tag`. Live attachments are the truth; the
2899
+ // persisted store fills in cold bindings so a peer nobody has talked to since
2900
+ // the last reload is still discoverable (and revivable) rather than invisible.
2901
+ const chatTargets = (target) => {
2902
+ const base = basePrincipalOf(target);
2903
+ const keys = new Set([...byTarget.keys(), ...Object.keys(deps.store.all())]);
2904
+ return Array.from(keys).filter((k) => basePrincipalOf(k) === base).sort();
2905
+ };
2906
+ const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
2907
+ const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
2908
+ // Busy ≡ the pane is showing an interrupt hint. A dead or unbound pane is
2909
+ // reported idle, not busy: "nothing is running there" is the truthful answer
2910
+ // and it keeps a graph step from blocking forever on a session that vanished.
2911
+ const paneBusy = async (pane) => !!pane && (await tmuxPaneAlive(pane)) && paneIsBusy(await capturePaneTail(pane, 12));
2912
+ const isBusy = (target) => paneBusy(paneOf(target));
2913
+ const lastText = (target) => {
2914
+ const p = jsonlOf(target);
2915
+ return p ? lastAssistantText(p) : "";
2916
+ };
2917
+ const peers = async (target) => {
2918
+ const list = await Promise.all(chatTargets(target).map(async (t) => {
2919
+ const a = byTarget.get(t);
2920
+ const rec = deps.store.get(t);
2921
+ const jsonlPath = jsonlOf(t);
2922
+ const pane = paneOf(t);
2923
+ const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
2924
+ const tag = tagOfTarget(t);
2925
+ let lastActivity = 0;
2926
+ try {
2927
+ if (jsonlPath)
2928
+ lastActivity = statSync(jsonlPath).mtimeMs;
2929
+ }
2930
+ catch { /* not written yet */ }
2931
+ return {
2932
+ target: t,
2933
+ tag,
2934
+ label: tag ? labelFor(tag) : "▫️",
2935
+ sessionId: a?.sessionId || rec?.sessionId || "",
2936
+ jsonlPath,
2937
+ cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
2938
+ cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
2939
+ tmuxPane: pane,
2940
+ attached: !!a,
2941
+ paneAlive,
2942
+ busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
2943
+ lastActivity,
2944
+ summary: jsonlPath ? summarizeTail(jsonlPath) : "(未绑定会话)",
2945
+ self: t === target,
2946
+ };
2947
+ }));
2948
+ return list.sort((x, y) => y.lastActivity - x.lastActivity);
2949
+ };
2950
+ // Capture a few extra rows then compact away the TUI's blank padding, so
2951
+ // `rows` counts lines the user actually cares about.
2952
+ const peekPane = async (target, rows = 24) => {
2953
+ const pane = paneOf(target);
2954
+ if (!pane)
2955
+ return { ok: false, reason: "no tmux pane bound for target" };
2956
+ if (!(await tmuxPaneAlive(pane)))
2957
+ return { ok: false, reason: "tmux pane no longer alive — the session needs /new or a respawn" };
2958
+ const raw = await capturePaneTail(pane, Math.max(8, rows) + 12);
2959
+ return { ok: true, pane: compactPane(raw, rows), busy: paneIsBusy(raw) };
2960
+ };
2961
+ return {
2962
+ attach,
2963
+ peers,
2964
+ peekPane,
2965
+ isBusy,
2966
+ lastText,
2967
+ status: () => {
2968
+ const list = Array.from(bySessionId.values()).map((a) => ({
2969
+ sessionId: a.sessionId,
2970
+ jsonlPath: a.jsonlPath,
2971
+ target: a.target,
2972
+ }));
2973
+ // Keep a single-attach view for back-compat; first entry wins when there's
2974
+ // exactly one mirror, callers iterating `mirrors` get the full picture.
2975
+ const first = list[0];
2976
+ return first
2977
+ ? { attached: true, mirrors: list, sessionId: first.sessionId, jsonlPath: first.jsonlPath, target: first.target }
2978
+ : { attached: false, mirrors: [] };
2979
+ },
2980
+ resolveToolDetail,
2981
+ hasMirrorTarget: (principal) => {
2982
+ if (byTarget.has(principal))
2983
+ return true;
2984
+ // Lazy: a persisted binding counts as "attached" if its transcript is
2985
+ // still on disk. The actual re-attach happens on dispatch (async).
2986
+ const rec = deps.store.get(principal);
2987
+ if (!rec)
2988
+ return false;
2989
+ return existsSync(expandHome(rec.jsonlPath));
2990
+ },
2991
+ targetForSession: (sessionId) => {
2992
+ // Live attach is the fast path. Fall back to scanning persisted store:
2993
+ // an MCP tool (e.g. `cd`) called from a claude that isn't mirror-attached
2994
+ // would otherwise miss here and silently retarget to defaultChat — that
2995
+ // bug stranded pendingCwd on the wrong principal. Store keeps each
2996
+ // target's sessionId in sync via migrate/attach/setPendingCwd writes.
2997
+ const live = bySessionId.get(sessionId)?.target;
2998
+ if (live)
2999
+ return live;
3000
+ const all = deps.store.all();
3001
+ for (const [target, rec] of Object.entries(all)) {
3002
+ if (rec.sessionId === sessionId)
3003
+ return target;
3004
+ }
3005
+ return undefined;
3006
+ },
3007
+ targetForPane: (tmuxPane) => {
3008
+ const pane = (tmuxPane ?? "").trim();
3009
+ if (!pane)
3010
+ return undefined;
3011
+ for (const a of bySessionId.values()) {
3012
+ if (a.tmuxPane === pane)
3013
+ return a.target;
3014
+ }
3015
+ for (const [target, rec] of Object.entries(deps.store.all())) {
3016
+ if ((rec.tmuxPane ?? "") === pane)
3017
+ return target;
3018
+ }
3019
+ return undefined;
3020
+ },
3021
+ terminateLiveStream: (sessionId) => {
3022
+ const a = bySessionId.get(sessionId);
3023
+ if (!a)
3024
+ return;
3025
+ if (a.liveStream && !a.liveStream.closed) {
3026
+ log.info({ sessionId, turnId: a.liveStream.turnId }, "approval click — terminating liveStream");
3027
+ void finalizeStream(a, a.liveStream);
3028
+ }
3029
+ // Promote AWAITING_APPR → STREAMING on any approval click (allow / deny /
3030
+ // allow_window — claude resumes after each, producing tool_result + text
3031
+ // that need a stream destination). If frame is stale (>~6min from inbound),
3032
+ // the first flushStream will reject, mark s.dead=true, and subsequent items
3033
+ // route to standalone via the existing :1206 fall-through. Self-healing.
3034
+ if (a.outbound?.kind === "awaiting_appr") {
3035
+ const { frame, streamId } = a.outbound;
3036
+ a.outbound = undefined;
3037
+ const s = openStream(a, frame, streamId);
3038
+ a.liveStream = s;
3039
+ log.info({ sessionId, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (approved)");
3040
+ }
3041
+ },
3042
+ // 解决"卡片先于思考过程到达 WeCom"的赛跑: hook 触发的 /approve 直接走
3043
+ // client.sendMessage 发卡, 而 mirror 这条管道里同一 turn 的 text/tool_use 还
3044
+ // 可能卡在三处 — DEFERRED 的 buf、standaloneBuf 的 3s 防抖、liveStream 的
3045
+ // 250ms flush。逐一强制 drain, 再 await standalonePending FIFO, 让发卡前 mirror
3046
+ // 已经把"为什么发这张卡"推完。
3047
+ flushBeforeCard: async (sessionId, expect) => {
3048
+ const a = bySessionId.get(sessionId);
3049
+ if (!a)
3050
+ return;
3051
+ // 1) fs.watch 通常即时, 但本调用是发卡前最后一道屏障 — 主动拽一次 tail,
3052
+ // 把刚落盘的 assistant 行(可能含 text + tool_use)立刻喂给 onItem。
3053
+ // 若 caller 给了 expect, 进一步轮询 drain 直到对应 tool_use 落盘 —
3054
+ // Claude Code 的 jsonl 写入相对 hook fire 有 tens-to-hundreds ms 的
3055
+ // 异步抖动, 单次 drain 经常抓空, 最终用户先看到卡再看到为什么。
3056
+ const expectSig = expect ? toolUseSig(expect.toolName, expect.toolInput) : "";
3057
+ if (expectSig) {
3058
+ const deadline = Date.now() + cfg.wrc.mirror.flushBeforeCardWaitMs;
3059
+ let polls = 0;
3060
+ while (Date.now() < deadline) {
3061
+ try {
3062
+ a.tail.drain();
3063
+ }
3064
+ catch (e) {
3065
+ log.warn({ sessionId, err: e.message }, "flushBeforeCard tail drain failed");
3066
+ }
3067
+ if (a.recentToolSigs.has(expectSig))
3068
+ break;
3069
+ polls++;
3070
+ await sleepMs(50);
3071
+ }
3072
+ if (!a.recentToolSigs.has(expectSig)) {
3073
+ log.warn({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard wait timed out — sending card without sig confirm");
3074
+ }
3075
+ else if (polls > 0) {
3076
+ log.info({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard waited for tool_use to materialize");
3077
+ }
3078
+ }
3079
+ else {
3080
+ try {
3081
+ a.tail.drain();
3082
+ }
3083
+ catch (e) {
3084
+ log.warn({ sessionId, err: e.message }, "flushBeforeCard tail drain failed");
3085
+ }
3086
+ }
3087
+ // 1.5) 门控 tool_use 始终没在 jsonl 落盘 → CC 把整条 tool-terminated turn 攒着
3088
+ // 等工具 resolve 才 flush(而工具正卡在这张卡上), 前言此刻既不在 jsonl 也
3089
+ // 不在 hook 的 transcript_tail 里, 只剩活着的 pane 有。抠出来先推, 稍后
3090
+ // jsonl 落盘 tail 出来的同一条由 isOwnAssistantSend 抑制。
3091
+ const sigConfirmed = expectSig !== "" && a.recentToolSigs.has(expectSig);
3092
+ if (expect && !sigConfirmed) {
3093
+ await sendPanePreamble(a);
3094
+ }
3095
+ // 2) DEFERRED 状态如果还在 buffering(needsApproval 因任何原因没触发就走到这),
3096
+ // 这里手动提升 → 立刻把 buf 当 standalone 推出, 进入 AWAITING_APPR。
3097
+ if (a.outbound?.kind === "deferred" && a.outbound.buf.length > 0) {
3098
+ promoteToStandalone(a);
3099
+ }
3100
+ // 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
3101
+ if (a.standaloneBuf) {
3102
+ clearTimeout(a.standaloneBuf.timer);
3103
+ flushStandalone(a);
3104
+ }
3105
+ // 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
3106
+ // 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
3107
+ const ls = a.liveStream;
3108
+ if (ls && !ls.closed && !ls.dead && ls.acc && ls.acc !== ls.lastSent) {
3109
+ if (ls.flushTimer) {
3110
+ clearTimeout(ls.flushTimer);
3111
+ ls.flushTimer = undefined;
3112
+ }
3113
+ await flushStream(ls);
3114
+ }
3115
+ // 5) 上面的 sendStandalone 都串在 standalonePending FIFO 上, 等它把 WeCom
3116
+ // 投递落地, 才算前奏到位 — 这步是关键, 确保返回时卡片可以安心发。
3117
+ try {
3118
+ await a.standalonePending;
3119
+ }
3120
+ catch {
3121
+ // 队列内单条失败已在 sendStandalone 里 warn 过, 这里吞掉, 不阻塞发卡。
3122
+ }
3123
+ },
3124
+ injectText: async (target, text) => {
3125
+ // Lazy restore, same as dispatch: a peer session that hasn't been talked
3126
+ // to in this process lifetime (or any session after a reload) has an empty
3127
+ // in-memory slot but a perfectly good persisted binding. Without this, an
3128
+ // agent driving a cold sibling gets "not attached" for a live pane.
3129
+ const a = byTarget.get(target) ?? (await restoreFromStore(target));
3130
+ if (!a)
3131
+ return { ok: false, reason: "no mirror attached for target" };
3132
+ if (!text.trim())
3133
+ return { ok: false, reason: "empty text" };
3134
+ // Pre-record so the tail's user-line emission is suppressed by the
3135
+ // recentInjects dedupe (otherwise the user sees their own demo prompt
3136
+ // echoed back as a quoted bubble).
3137
+ rememberInject(text);
3138
+ const sid = a.sessionId;
3139
+ const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
3140
+ if (!paneAlive) {
3141
+ log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
3142
+ // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
3143
+ // onto the forked jsonl once it appears (EOF offset — fork is seeded).
3144
+ const resumeBaseline = listJsonls(dirname(a.jsonlPath));
3145
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name });
3146
+ if (!r.ok || !r.tmuxPane)
3147
+ return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
3148
+ a.tmuxPane = r.tmuxPane;
3149
+ a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
3150
+ if (r.cwd)
3151
+ a.runningCwd = r.cwd;
3152
+ deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, pendingCwd: a.pendingCwd || undefined });
3153
+ startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
3154
+ }
3155
+ // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
3156
+ // is still warming up so the verifier in injectViaTmux needs the slack.
3157
+ return await inject({
3158
+ text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
3159
+ sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
3160
+ });
3161
+ },
3162
+ interruptPane: async (target) => {
3163
+ const a = byTarget.get(target);
3164
+ if (!a)
3165
+ return { ok: false, reason: "no mirror attached for target" };
3166
+ if (!a.tmuxPane)
3167
+ return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
3168
+ const alive = await tmuxPaneAlive(a.tmuxPane);
3169
+ if (!alive)
3170
+ return { ok: false, reason: "tmux pane no longer alive" };
3171
+ const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
3172
+ if (r.code !== 0)
3173
+ return { ok: false, reason: `send-keys Escape failed: ${r.stdout.slice(-200) || r.code}` };
3174
+ log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /stop — Esc sent to pane");
3175
+ return { ok: true };
3176
+ },
3177
+ submitPane: async (target) => {
3178
+ const a = byTarget.get(target);
3179
+ if (!a)
3180
+ return { ok: false, reason: "no mirror attached for target" };
3181
+ if (!a.tmuxPane)
3182
+ return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
3183
+ const alive = await tmuxPaneAlive(a.tmuxPane);
3184
+ if (!alive)
3185
+ return { ok: false, reason: "tmux pane no longer alive" };
3186
+ const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Enter"]);
3187
+ if (r.code !== 0)
3188
+ return { ok: false, reason: `send-keys Enter failed: ${r.stdout.slice(-200) || r.code}` };
3189
+ log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
3190
+ return { ok: true };
3191
+ },
3192
+ revealPane: async (target) => {
3193
+ // paneOf (not byTarget.get) so a cold binding surviving only in the
3194
+ // persisted store can still be revealed after a daemon reload.
3195
+ const pane = paneOf(target);
3196
+ if (!pane)
3197
+ return { ok: false, reason: "no tmux pane bound for target" };
3198
+ if (!(await tmuxPaneAlive(pane)))
3199
+ return { ok: false, reason: "tmux pane no longer alive — the session needs /new or a respawn" };
3200
+ // Only clients already attached to the pane's OWN session are candidates:
3201
+ // switching a client that sits on an unrelated session would yank the
3202
+ // user's other terminal window into weclaude. Most-recently-active wins —
3203
+ // switch-client with no -c is ambiguous under multiple clients.
3204
+ const s = await tmuxRun(["display-message", "-p", "-t", pane, "#{session_name}"]);
3205
+ const sess = s.stdout.trim() || cfg.wrc.tmuxPrefix;
3206
+ const clients = await tmuxRun(["list-clients", "-t", sess, "-F", "#{client_activity} #{client_name}"]);
3207
+ const best = clients.stdout.split("\n").filter(Boolean)
3208
+ .map((l) => { const i = l.indexOf(" "); return { act: Number(l.slice(0, i)), name: l.slice(i + 1) }; })
3209
+ .sort((x, y) => y.act - x.act)[0]?.name;
3210
+ if (!best) {
3211
+ // Nothing to switch; tell the user how to get a client onto the session.
3212
+ return { ok: false, reason: `没有 attach 到 \`${sess}\` 的 tmux 客户端。先在终端执行: \`tmux attach -t ${sess}\`` };
3213
+ }
3214
+ // A pane target pulls session + window selection along with it.
3215
+ const r = await tmuxRun(["switch-client", "-c", best, "-t", pane]);
3216
+ if (r.code !== 0)
3217
+ return { ok: false, reason: `switch-client failed: ${r.stdout.slice(-200) || r.code}` };
3218
+ log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
3219
+ return { ok: true };
3220
+ },
3221
+ getCwd,
3222
+ setPendingCwd,
3223
+ newSession,
3224
+ shutdown: () => {
3225
+ clearInterval(paneDriftTimer);
3226
+ for (const a of bySessionId.values()) {
3227
+ if (a.outbound?.kind === "deferred")
3228
+ clearTimeout(a.outbound.timer);
3229
+ a.outbound = undefined;
3230
+ if (a.liveStream && !a.liveStream.closed)
3231
+ void finalizeStream(a, a.liveStream);
3232
+ a.tail.stop();
3233
+ }
3234
+ bySessionId.clear();
3235
+ byTarget.clear();
3236
+ },
3237
+ dispatch: async ({ principal, text, images, frame, streamId }) => {
3238
+ // Route by inbound principal — the WeCom chat the user just messaged us
3239
+ // from is the same string we registered as `target` on attach. After a
3240
+ // daemon reload the in-memory map is empty; restore from the persisted
3241
+ // store before giving up so the conversation continues in the prior
3242
+ // claude session instead of getting "not attached".
3243
+ let a = byTarget.get(principal);
3244
+ if (!a)
3245
+ a = await restoreFromStore(principal);
3246
+ if (!a) {
3247
+ try {
3248
+ await client.replyStream(frame, streamId, withSessionTag(principal, "[weclaude] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
3249
+ }
3250
+ catch {
3251
+ /* ignore */
3252
+ }
3253
+ return;
3254
+ }
3255
+ // Finalize prior live stream (if any) so this new turn renders into its
3256
+ // own message bubble. Then open a fresh stream tied to the new frame and
3257
+ // ack immediately so WeCom doesn't time out while inject queues.
3258
+ const armMigration = isClearCommand(text);
3259
+ // `/model <arg>` opens the TUI picker preselected on the match — the
3260
+ // switch only lands after a confirm Enter. Bare `/model` is excluded:
3261
+ // auto-confirming it would just re-pick the current model.
3262
+ const isModelSwitch = /^\/model\s+\S/.test(text.trim());
3263
+ // 任意 slash 命令 (/context, /cost, /status…): 产物走 skill_output standalone,
3264
+ // 详情链接是纯噪声 —— brief 起 turn 时不推链接。
3265
+ const isSlash = /^\/[a-z]/i.test(text.trim());
3266
+ // Auto-upgrade /clear → /new when the user has queued a project switch:
3267
+ // a plain /clear would only rotate sessionId in the same pane, which sits
3268
+ // in the OLD cwd. Killing+respawning is the only way to honor the switch.
3269
+ // pendingCwd is chat-scoped, so read it via the shared fallback rather
3270
+ // than from this attachment's own record (which is now always empty for
3271
+ // tagged sessions).
3272
+ const pending = chatCwdFallback(a.target).pending;
3273
+ if (armMigration && pending && pending !== a.runningCwd) {
3274
+ if (a.liveStream && !a.liveStream.closed)
3275
+ await finalizeStream(a, a.liveStream);
3276
+ log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
3277
+ // Prefer the tag suffix as tmux window name when present (matches
3278
+ // /new #tag behavior), fall back to the full target for untagged.
3279
+ const tag = tagOfTarget(a.target);
3280
+ const r = await newSession(a.target, tag || a.target);
3281
+ if (!r.ok) {
3282
+ try {
3283
+ await client.replyStream(frame, streamId, withSessionTag(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
3284
+ }
3285
+ catch { /* ignore */ }
3286
+ }
3287
+ return;
3288
+ }
3289
+ // Snapshot the project dir BEFORE inject runs. Claude rotates the session
3290
+ // synchronously while processing /clear and writes the rotated jsonl
3291
+ // immediately — capturing baseline post-inject would already include it,
3292
+ // and the watcher would never see a "new" candidate (the bug this fixes).
3293
+ const preClearBaseline = armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
3294
+ // Drop any prior turn's outbound deferral state — a new dispatch always
3295
+ // supersedes whatever was buffered/awaiting. The old frame is dead by our
3296
+ // own choice (we won't write to it anymore); the user might still later
3297
+ // click an old approval card, but terminateLiveStream will find no
3298
+ // matching outbound slot and no-op gracefully.
3299
+ if (a.outbound) {
3300
+ if (a.outbound.kind === "deferred")
3301
+ clearTimeout(a.outbound.timer);
3302
+ a.outbound = undefined;
3303
+ }
3304
+ // Drain any pending standalone debounce so a prior turn's tail tail
3305
+ // doesn't get sandwiched into this turn's pre-card flush (Path A) or
3306
+ // race against the new stream's first content (Path B).
3307
+ if (a.standaloneBuf) {
3308
+ clearTimeout(a.standaloneBuf.timer);
3309
+ flushStandalone(a);
3310
+ }
3311
+ // /clear produces no assistant output; opening a stream would leave a
3312
+ // stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
3313
+ // success path; only surface a terse "clean" if inject fails.
3314
+ if (a.liveStream && !a.liveStream.closed) {
3315
+ await finalizeStream(a, a.liveStream);
3316
+ }
3317
+ // Three paths from here:
3318
+ // • armMigration → s undefined, no defer (existing /clear behavior)
3319
+ // • outboundDeferMs > 0 → DEFERRED slot; stream opens later via promote
3320
+ // • outboundDeferMs === 0 → eager openStream + "…" ack (legacy behavior)
3321
+ const brief = cfg.wrc.mirror.brief && !armMigration;
3322
+ const eagerOpen = !armMigration && !brief && cfg.wrc.mirror.outboundDeferMs <= 0;
3323
+ const s = eagerOpen ? openStream(a, frame, streamId) : undefined;
3324
+ if (s) {
3325
+ a.liveStream = s;
3326
+ try {
3327
+ await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false);
3328
+ }
3329
+ catch (e) {
3330
+ log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
3331
+ }
3332
+ }
3333
+ if (brief) {
3334
+ // 挂 loading 气泡并起新 turn (上一 turn 还在跑则排队, 由它收口时放行)。
3335
+ // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
3336
+ await startBriefTurn(a, frame, streamId, isSlash, text);
3337
+ }
3338
+ else if (!armMigration && !eagerOpen) {
3339
+ enterDeferred(a, frame, streamId);
3340
+ }
3341
+ const sid = a.sessionId;
3342
+ await enqueue(sid, async () => {
3343
+ if (s && s.closed)
3344
+ return; // (eager path) superseded by a newer dispatch
3345
+ // Always reincarnate when no live pane: covers (a) pane closed between
3346
+ // turns, (b) daemon reload restored a binding without a live pane, AND
3347
+ // (c) /wrc'd from a non-tmux context (no tmuxSession ever stored) —
3348
+ // that last case used to permanently lock the chat into spawn-mode.
3349
+ // If respawn fails, fall through to spawn-mode inject for THIS turn
3350
+ // but DON'T erase tmuxSession from store — next inbound will retry,
3351
+ // making the system self-healing instead of one-failure-permanent.
3352
+ const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
3353
+ let freshSpawn = a.justSpawned === true;
3354
+ a.justSpawned = false;
3355
+ if (!paneAlive) {
3356
+ log.warn({ target: a.target, sessionId: sid, oldPane: a.tmuxPane, oldSession: a.tmuxSession }, "mirror: no live tmux pane, respawning");
3357
+ // Interactive `claude --resume <sid>` does NOT keep appending to the
3358
+ // same <sid>.jsonl — it FORKS to a fresh <newSid>.jsonl seeded with a
3359
+ // copy of the transcript. Our tail stays bound to the OLD, now-frozen
3360
+ // jsonl, so the chat would go silent after respawn. Snapshot the
3361
+ // project dir BEFORE spawn so the fork surfaces as a new file the
3362
+ // watcher can re-bind onto. Skip when /clear already owns a watcher
3363
+ // for this turn (its migration supersedes the fork).
3364
+ const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
3365
+ // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
3366
+ // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
3367
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name });
3368
+ if (r.ok && r.tmuxPane && r.tmuxSession) {
3369
+ a.tmuxPane = r.tmuxPane;
3370
+ a.tmuxSession = r.tmuxSession;
3371
+ if (r.cwd)
3372
+ a.runningCwd = r.cwd;
3373
+ freshSpawn = true;
3374
+ deps.store.set(a.target, {
3375
+ sessionId: sid,
3376
+ jsonlPath: a.jsonlPath,
3377
+ tmuxSession: a.tmuxSession,
3378
+ tmuxPane: a.tmuxPane,
3379
+ cwd: a.runningCwd || undefined,
3380
+ pendingCwd: a.pendingCwd || undefined,
3381
+ });
3382
+ log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
3383
+ // Re-bind onto the resume fork once it appears (EOF offset: the fork
3384
+ // already holds the full prior transcript — replaying from 0 would
3385
+ // re-dump it). If --resume happens to keep the same jsonl, no new
3386
+ // file appears, the watcher times out harmlessly, and the existing
3387
+ // tail keeps working — safe under either behavior.
3388
+ if (resumeBaseline)
3389
+ startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
3390
+ }
3391
+ else {
3392
+ // Drop only the stale pane id; keep tmuxSession (if any) so the
3393
+ // store still reflects "user wanted tmux" — next turn retries.
3394
+ log.error({ reason: r.reason }, "mirror: tmux respawn failed, this turn falls back to spawn-mode");
3395
+ a.tmuxPane = "";
3396
+ }
3397
+ }
3398
+ // Remember BEFORE inject (not after success): a fresh-spawn paste can
3399
+ // submit late, after our verifier reports failure. The tail still
3400
+ // surfaces the user's text, and without a pre-recorded entry the
3401
+ // dedupe filter wouldn't suppress it — user's chat msg gets echoed
3402
+ // back as a quoted bubble. Recording up front fixes that.
3403
+ rememberInject(text);
3404
+ const r = await inject({
3405
+ text, images, cfg, log: log.child({ principal, sessionId: sid }),
3406
+ sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
3407
+ });
3408
+ if (!r.ok) {
3409
+ if (s) {
3410
+ s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
3411
+ await finalizeStream(a, s);
3412
+ }
3413
+ else if (armMigration) {
3414
+ // /clear path has no live stream — surface failure as a one-shot
3415
+ // terse reply ("clean" per project convention).
3416
+ try {
3417
+ await client.replyStream(frame, streamId, withSessionTag(a.target, "clean"), true);
3418
+ }
3419
+ catch { /* ignore */ }
3420
+ }
3421
+ else {
3422
+ // Deferred path: tear down outbound, surface error as standalone.
3423
+ // promote* may have already cleared the slot if a tail item raced
3424
+ // ahead of inject completion — in that case just send the error.
3425
+ if (a.outbound?.kind === "deferred")
3426
+ clearTimeout(a.outbound.timer);
3427
+ a.outbound = undefined;
3428
+ sendStandalone(a, `[mirror] ✗ ${r.reason ?? "failed"}`);
3429
+ }
3430
+ return;
3431
+ }
3432
+ // Inject "succeeded" but the input box never cleared — the target
3433
+ // session may be busy / not consuming input (long task, full context).
3434
+ // Hint the user once so a silently-dropped message isn't mistaken for a
3435
+ // weclaude bug. Skip on the /clear path (armMigration), which has its
3436
+ // own "cleared" feedback below.
3437
+ //
3438
+ // OFF by default: the clear-check has a structural false-positive (the
3439
+ // tailFp can match the message's own echo line just above the input box
3440
+ // when it falls in the 5-row capture window), so in the common mirror
3441
+ // case this fires on essentially every message even though it landed
3442
+ // fine — pure noise. A genuinely dropped message still surfaces as the
3443
+ // `[mirror] ✗` hard failure above. Opt back in with
3444
+ // WECLAUDE_WARN_UNCERTAIN_INJECT=1 if you want the (noisy) heads-up.
3445
+ if (r.uncertain && !armMigration && process.env.WECLAUDE_WARN_UNCERTAIN_INJECT === "1") {
3446
+ sendStandalone(a, `[mirror] ⚠️ 消息已发送,但目标会话似乎正忙或未响应(输入框未清空),可能未被处理。可稍后重试,或用 \`/sessions\` 切到其它会话。`);
3447
+ }
3448
+ // Follow a user-initiated same-dir fork (/clear or manual restart in the
3449
+ // live TUI) that would otherwise leave the tail — and the "…" bubble —
3450
+ // stuck on the dead old jsonl. /clear via WeCom (armMigration) has its
3451
+ // own watcher; skip spawn-mode (no shared pane to fork under us).
3452
+ if (!armMigration && a.tmuxPane)
3453
+ armSilentForkRebind(a, text);
3454
+ // Confirm the /model picker. Early Enter (before the picker renders)
3455
+ // would leave it unconfirmed, so settle first; a late Enter is a
3456
+ // harmless no-op in the empty input box. On confirm, claude writes
3457
+ // "Set model to …" as <local-command-stdout> — the tail mirrors that
3458
+ // back to the chat as a ⚙️ bubble, which IS the readback.
3459
+ if (isModelSwitch && a.tmuxPane) {
3460
+ await sleepMs(1000);
3461
+ const e = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Enter"]);
3462
+ if (e.code !== 0)
3463
+ log.warn({ pane: a.tmuxPane, reason: e.stdout.slice(-200) || e.code }, "/model confirm Enter failed — user can send /n manually");
3464
+ }
3465
+ // /clear was just injected — claude rotates sessionId on the next user
3466
+ // input. Arm a watcher to migrate the attachment onto the new jsonl,
3467
+ // and surface a standalone "cleared" so the user gets explicit
3468
+ // feedback (the skip-stream path otherwise leaves WeCom silent).
3469
+ if (armMigration) {
3470
+ sendStandalone(a, `cleared\n\n${renderProjectInfo(a.target)}`);
3471
+ startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0);
3472
+ }
3473
+ // Don't await the stream's lifetime — it stays open until next inbound
3474
+ // or hard timeout. Releasing the inject queue here lets the next
3475
+ // dispatch start its inject promptly while tail content keeps flowing
3476
+ // into the (still-open) stream until superseded.
3477
+ });
3478
+ },
3479
+ };
3480
+ };
3481
+ const TOOL_DETAIL_PREFIX_EXT = "TOOL_DETAIL|";
3482
+ // Install a click-handler for the "查看详情" button. On click, look up the
3483
+ // turn's tool entries and push them as standalone markdown messages to the
3484
+ // originating chat.
3485
+ export const installMirrorEventListener = (client, bridge, log) => {
3486
+ client.on("event.template_card_event", (frame) => {
3487
+ const ev = frame.body?.event;
3488
+ const key = ev?.event_key ?? "";
3489
+ if (!key.startsWith(TOOL_DETAIL_PREFIX_EXT))
3490
+ return;
3491
+ const turnId = key.slice(TOOL_DETAIL_PREFIX_EXT.length);
3492
+ const detail = bridge.resolveToolDetail(turnId);
3493
+ if (!detail) {
3494
+ log.warn({ turnId }, "tool detail expired or unknown");
3495
+ return;
3496
+ }
3497
+ const chatId = stripPrincipalPrefix(detail.target);
3498
+ void (async () => {
3499
+ const n = detail.markdown.length;
3500
+ for (const [i, md] of detail.markdown.entries()) {
3501
+ try {
3502
+ const content = withSessionTag(detail.target, md, n > 1 ? `${i + 1}/${n}` : undefined);
3503
+ await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
3504
+ }
3505
+ catch (e) {
3506
+ log.warn({ err: e.message, turnId }, "tool detail push failed");
3507
+ }
3508
+ }
3509
+ })();
3510
+ });
3511
+ };
3512
+ //# sourceMappingURL=mirror-bridge.js.map