weclaude 0.0.4 → 0.1.0

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 (48) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +34 -22
  3. package/cli/{wrc.sh → weclaude.sh} +34 -18
  4. package/commands/wrc.md +4 -4
  5. package/config.example.jsonc +6 -6
  6. package/dist/cli/init.js +10 -10
  7. package/dist/cli/init.js.map +1 -1
  8. package/dist/cli/sync.js +35 -18
  9. package/dist/cli/sync.js.map +1 -1
  10. package/dist/daemon/approval.js +480 -36
  11. package/dist/daemon/approval.js.map +1 -1
  12. package/dist/daemon/cc-bridge.js +37 -20
  13. package/dist/daemon/cc-bridge.js.map +1 -1
  14. package/dist/daemon/claim.js +1 -1
  15. package/dist/daemon/claim.js.map +1 -1
  16. package/dist/daemon/detail.js +500 -0
  17. package/dist/daemon/detail.js.map +1 -0
  18. package/dist/daemon/http.js +2 -1
  19. package/dist/daemon/http.js.map +1 -1
  20. package/dist/daemon/inbound.js +115 -21
  21. package/dist/daemon/inbound.js.map +1 -1
  22. package/dist/daemon/index.js +24 -7
  23. package/dist/daemon/index.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +972 -151
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/mirror-store.js +39 -0
  27. package/dist/daemon/mirror-store.js.map +1 -0
  28. package/dist/daemon/pending.js +46 -0
  29. package/dist/daemon/pending.js.map +1 -1
  30. package/dist/daemon/session-cache.js +71 -3
  31. package/dist/daemon/session-cache.js.map +1 -1
  32. package/dist/daemon/spawn-tmux.js +132 -0
  33. package/dist/daemon/spawn-tmux.js.map +1 -0
  34. package/dist/mcp/server.js +104 -65
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/shared/config-writer.js +1 -1
  37. package/dist/shared/config.js +34 -20
  38. package/dist/shared/config.js.map +1 -1
  39. package/dist/shared/paths.js +6 -0
  40. package/dist/shared/paths.js.map +1 -1
  41. package/docs/DESIGN-INIT.md +6 -6
  42. package/docs/ONBOARDING.md +25 -25
  43. package/hooks/pre-tool-use.sh +32 -7
  44. package/launchd/{com.cc-wecom.daemon.plist.template → com.weclaude.daemon.plist.template} +3 -3
  45. package/package.json +10 -11
  46. package/scripts/install.sh +6 -6
  47. package/scripts/uninstall.sh +3 -3
  48. package/systemd/{cc-wecom.service.template → weclaude.service.template} +3 -3
@@ -15,7 +15,9 @@
15
15
  import { spawn } from "node:child_process";
16
16
  import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
17
17
  import { join, dirname } from "node:path";
18
- import { expandHome } from "../shared/paths.js";
18
+ import { expandHome, sanitizeId } from "../shared/paths.js";
19
+ import { spawnTmuxClaude } from "./spawn-tmux.js";
20
+ import { recordTool, recordToolResult, buildDetailUrl } from "./detail.js";
19
21
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
20
22
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
21
23
  const NODE_BIN_DIR = dirname(process.execPath);
@@ -75,6 +77,30 @@ const stripPrincipalPrefix = (s) => {
75
77
  const i = s.indexOf(":");
76
78
  return i >= 0 ? s.slice(i + 1) : s;
77
79
  };
80
+ // Claude Code wraps slash-command invocations into the user message as
81
+ // <command-message>name</command-message>
82
+ // <command-name>/name</command-name>
83
+ // <command-args>...</command-args>
84
+ // plus assorted <local-command-stdout>, <local-command-caveat>,
85
+ // <system-reminder> blocks. Rendering those raw to WeCom is pure noise.
86
+ //
87
+ // Strategy: extract /cmd + args into a single styled line; strip the rest.
88
+ // Returns "" when the message is purely meta — caller filters.
89
+ const SLASH_TAG_RE = /<command-name>([\s\S]*?)<\/command-name>/;
90
+ const SLASH_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/;
91
+ const META_TAG_RE = /<(command-message|command-name|command-args|local-command-stdout|local-command-caveat|system-reminder)>[\s\S]*?<\/\1>/g;
92
+ const cleanUserText = (raw) => {
93
+ const nameMatch = raw.match(SLASH_TAG_RE);
94
+ const slashCmd = nameMatch?.[1]?.trim() ?? "";
95
+ const argsMatch = raw.match(SLASH_ARGS_RE);
96
+ const slashArgs = argsMatch?.[1]?.trim() ?? "";
97
+ const stripped = raw.replace(META_TAG_RE, "").trim();
98
+ if (slashCmd) {
99
+ const head = `\`${slashCmd}${slashArgs ? ` ${slashArgs}` : ""}\``;
100
+ return stripped ? `${head}\n${stripped}` : head;
101
+ }
102
+ return stripped;
103
+ };
78
104
  const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
79
105
  const renderToolInput = (input) => {
80
106
  try {
@@ -85,17 +111,39 @@ const renderToolInput = (input) => {
85
111
  return "{}";
86
112
  }
87
113
  };
88
- const renderToolResult = (block, max) => {
114
+ const renderToolResultFull = (block, max) => {
89
115
  const c = block.content;
90
116
  if (typeof c === "string")
91
117
  return truncate(c, max);
92
118
  if (!Array.isArray(c))
93
119
  return "";
94
- return truncate(c
95
- .map((b) => (typeof b?.text === "string" ? b.text : b?.tool_name ? `→ ${b.tool_name}` : ""))
120
+ return truncate(c.map((b) => (typeof b?.text === "string" ? b.text : b?.tool_name ? `→ ${b.tool_name}` : ""))
96
121
  .filter(Boolean)
97
122
  .join("\n"), max);
98
123
  };
124
+ const oneLineSummary = (s, max = 80) => {
125
+ const flat = s.replace(/\s+/g, " ").trim();
126
+ return truncate(flat, max);
127
+ };
128
+ // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
129
+ // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
130
+ // `<noreply@x>` etc. silently swallows the rest of the line plus the closing
131
+ // backtick, eating subsequent items. A literal backtick inside `compact` also
132
+ // closes the surrounding inline-code prematurely.
133
+ // Replace with full-width / similar glyphs: visually close, harmless to the
134
+ // renderer. Applied to the user-controlled part only — surrounding markdown
135
+ // structure (the wrapping ``…`` and `> ↩ ` prefix) stays literal.
136
+ const safeForMarkdown = (s) => s.replace(/`/g, "ʼ").replace(/</g, "<").replace(/>/g, ">");
137
+ const renderToolInputCompact = (input) => {
138
+ // Heuristic: prefer command/file_path/pattern-like keys for the inline summary.
139
+ if (input && typeof input === "object") {
140
+ const o = input;
141
+ const pick = o.command ?? o.file_path ?? o.path ?? o.pattern ?? o.url ?? o.query ?? o.prompt;
142
+ if (typeof pick === "string")
143
+ return oneLineSummary(pick);
144
+ }
145
+ return oneLineSummary(renderToolInput(input));
146
+ };
99
147
  // Render one transcript line into tagged items. Caller decides batching.
100
148
  const renderLine = (raw, deps) => {
101
149
  let line;
@@ -111,28 +159,38 @@ const renderLine = (raw, deps) => {
111
159
  if (line.type === "user") {
112
160
  const c = line.message?.content;
113
161
  if (typeof c === "string") {
114
- const text = c.trim();
115
- if (!text)
116
- return [];
117
162
  if (!deps.includeUser)
118
163
  return [];
164
+ const text = cleanUserText(c);
165
+ if (!text)
166
+ return []; // pure slash-command meta / stdout — drop
119
167
  if (deps.isOwnInject(text))
120
168
  return []; // dedupe WeCom→CLI echo
121
- // Blockquote each line; no heading. Distinguishes from assistant text
122
- // without screaming "🧑 user" at the top of every turn.
123
169
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
124
- out.push({ kind: "text", body: quoted });
170
+ out.push({ kind: "user_text", body: quoted });
125
171
  }
126
172
  else if (Array.isArray(c) && deps.includeToolResults) {
127
173
  for (const b of c) {
128
174
  if (b?.type === "tool_result") {
129
- const body = renderToolResult(b, deps.toolResultMaxChars);
130
- if (body) {
131
- out.push({
132
- kind: "tool",
133
- body: `**↩ tool_result** \`${b.tool_use_id?.slice(-8) ?? ""}\`\n${body}`,
134
- });
135
- }
175
+ const full = renderToolResultFull(b, deps.toolResultMaxChars);
176
+ if (!full)
177
+ continue;
178
+ const compact = safeForMarkdown(oneLineSummary(full, 100));
179
+ const toolUseId = b.tool_use_id ?? "";
180
+ // Persist the result onto the matching tool record so the detail
181
+ // page renders both input and result. recordTool may not have run
182
+ // yet if tool_use is in a not-yet-flushed assistant line, so the
183
+ // result is also kept on the RenderItem for the in-stream "查看详情"
184
+ // card fallback.
185
+ if (toolUseId)
186
+ recordToolResult(toolUseId, full);
187
+ const url = deps.detailUrlFor(toolUseId);
188
+ out.push({
189
+ kind: "tool_result",
190
+ toolUseId,
191
+ full,
192
+ body: url ? `[↩ ${compact}](${url})` : `↩ ${compact}`,
193
+ });
136
194
  }
137
195
  }
138
196
  }
@@ -149,9 +207,28 @@ const renderLine = (raw, deps) => {
149
207
  out.push({ kind: "text", body: t });
150
208
  }
151
209
  else if (b?.type === "tool_use" && deps.includeTools) {
210
+ const name = b.name ?? "tool";
211
+ const compact = safeForMarkdown(renderToolInputCompact(b.input));
212
+ const toolUseId = b.id ?? "";
213
+ // Persist the tool record so the click-to-detail page can render
214
+ // before the matching tool_result arrives — the detail HTML shows
215
+ // a "running…" placeholder until recordToolResult fills it in.
216
+ if (toolUseId) {
217
+ recordTool({
218
+ id: toolUseId,
219
+ toolName: name,
220
+ toolInput: b.input,
221
+ sessionId: deps.sessionId,
222
+ target: deps.target,
223
+ });
224
+ }
225
+ const url = deps.detailUrlFor(toolUseId);
152
226
  out.push({
153
- kind: "tool",
154
- body: `**🔧 ${b.name ?? "tool"}**\n\`\`\`json\n${renderToolInput(b.input)}\n\`\`\``,
227
+ kind: "tool_use",
228
+ toolUseId,
229
+ name,
230
+ input: b.input,
231
+ body: url ? `[🔧 ${name} ${compact}](${url})` : `🔧 ${name} ${compact}`,
155
232
  });
156
233
  }
157
234
  // thinking blocks intentionally skipped
@@ -170,8 +247,14 @@ const splitChunks = (s, max) => {
170
247
  };
171
248
  export const startMirrorTail = (deps) => {
172
249
  const { jsonlPath, log } = deps;
173
- // Start at EOF — don't re-emit history.
174
- let offset = statSync(jsonlPath).size;
250
+ // Start at EOF — don't re-emit history. File may not exist yet (auto-spawn
251
+ // path: claude doesn't create the jsonl until it processes the first input);
252
+ // start at 0 in that case so we capture everything once it appears.
253
+ // Caller-provided startOffset wins (used by /clear migration to replay the
254
+ // already-written user line + any early assistant lines from offset 0).
255
+ let offset = deps.startOffset !== undefined
256
+ ? deps.startOffset
257
+ : existsSync(jsonlPath) ? statSync(jsonlPath).size : 0;
175
258
  let buffer = "";
176
259
  let stopped = false;
177
260
  const drain = () => {
@@ -232,47 +315,248 @@ export const startMirrorTail = (deps) => {
232
315
  },
233
316
  };
234
317
  };
235
- const injectViaTmux = (target, text, log) => {
236
- log.info({ target, len: text.length }, "mirror inject (tmux)");
237
- return new Promise((resolve) => {
238
- // 1. load text into tmux paste buffer (handles multi-line / unicode safely)
239
- const loader = spawn("tmux", ["load-buffer", "-"], { stdio: ["pipe", "ignore", "pipe"] });
240
- let lerr = "";
241
- loader.stderr?.on("data", (c) => (lerr += c.toString("utf8")));
242
- loader.on("error", (e) => resolve({ ok: false, reason: `tmux not found: ${e.message}` }));
243
- loader.on("close", (code) => {
244
- if (code !== 0) {
245
- resolve({ ok: false, reason: `tmux load-buffer exit ${code}: ${lerr.slice(-200)}` });
246
- return;
247
- }
248
- // 2. paste with bracketed-paste (-p) so claude treats it as one chunk,
249
- // not auto-submitting on embedded newlines.
250
- const paster = spawn("tmux", ["paste-buffer", "-p", "-d", "-t", target], {
251
- stdio: ["ignore", "ignore", "pipe"],
252
- });
253
- let perr = "";
254
- paster.stderr?.on("data", (c) => (perr += c.toString("utf8")));
255
- paster.on("close", (c2) => {
256
- if (c2 !== 0) {
257
- resolve({ ok: false, reason: `tmux paste-buffer exit ${c2}: ${perr.slice(-200)}` });
258
- return;
259
- }
260
- // 3. tiny delay so paste settles in claude's input box, then Enter to submit.
261
- setTimeout(() => {
262
- const enter = spawn("tmux", ["send-keys", "-t", target, "Enter"], { stdio: "ignore" });
263
- enter.on("close", () => resolve({ ok: true }));
264
- enter.on("error", (e) => resolve({ ok: false, reason: `tmux send-keys: ${e.message}` }));
265
- }, 120);
318
+ // Run a tmux subcommand, capturing stdout/stderr. Local helper to avoid
319
+ // pulling spawn-tmux's runTmux into the bridge module graph.
320
+ const tmuxRun = (args) => new Promise((resolve) => {
321
+ const p = spawn("tmux", args, { stdio: ["ignore", "pipe", "pipe"] });
322
+ let out = "", err = "";
323
+ p.stdout?.on("data", (c) => (out += c.toString("utf8")));
324
+ p.stderr?.on("data", (c) => (err += c.toString("utf8")));
325
+ p.on("error", (e) => resolve({ ok: false, stdout: "", stderr: e.message }));
326
+ p.on("close", (code) => resolve({ ok: code === 0, stdout: out, stderr: err }));
327
+ });
328
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
329
+ // macOS clipboard image inject. Claude Code's TUI handles Ctrl+V by reading the
330
+ // system clipboard for image data and attaching it as an image content block in
331
+ // the next user turn no Read tool call, no permission prompt. To trigger that
332
+ // path we (a) put image bytes onto the clipboard with the right AppleScript
333
+ // pasteboard class, (b) send a literal C-v keystroke to the live tmux pane.
334
+ //
335
+ // Pasteboard class per source format:
336
+ // .png → «class PNGf»
337
+ // .jpg/.jpeg → JPEG picture
338
+ // .gif → «class GIFf»
339
+ // .tiff/.tif → «class TIFF»
340
+ // Anything else (webp/heic/...) we transcode to PNG via `sips` first; sips ships
341
+ // with macOS so no extra dep. Files are cached next to the original; cleanup is
342
+ // left to the inbox dir's regular eviction.
343
+ const PB_CLASS_BY_EXT = {
344
+ png: "«class PNGf»",
345
+ jpg: "JPEG picture",
346
+ jpeg: "JPEG picture",
347
+ gif: "«class GIFf»",
348
+ tif: "«class TIFF»",
349
+ tiff: "«class TIFF»",
350
+ };
351
+ const runProc = (cmd, args) => new Promise((resolve) => {
352
+ const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
353
+ let err = "";
354
+ p.stderr?.on("data", (c) => (err += c.toString("utf8")));
355
+ p.on("error", (e) => resolve({ ok: false, stderr: e.message }));
356
+ p.on("close", (code) => resolve({ ok: code === 0, stderr: err }));
357
+ });
358
+ const setMacClipboardImage = async (imgPath) => {
359
+ const ext = (imgPath.split(".").pop() ?? "").toLowerCase();
360
+ let pbClass = PB_CLASS_BY_EXT[ext];
361
+ let pathToUse = imgPath;
362
+ if (!pbClass) {
363
+ // Transcode to PNG so AppleScript can pull it onto the pasteboard.
364
+ const tmp = `${imgPath}.cb.png`;
365
+ const r = await runProc("sips", ["-s", "format", "png", imgPath, "--out", tmp]);
366
+ if (!r.ok)
367
+ return { ok: false, reason: `sips ${ext}→png failed: ${r.stderr.slice(-200)}` };
368
+ pathToUse = tmp;
369
+ pbClass = "«class PNGf»";
370
+ }
371
+ // POSIX path quoting: backslash-escape `\` and `"` for AppleScript string literal.
372
+ const escaped = pathToUse.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
373
+ const script = `set the clipboard to (read POSIX file "${escaped}" as ${pbClass})`;
374
+ const r = await runProc("osascript", ["-e", script]);
375
+ if (!r.ok)
376
+ return { ok: false, reason: `osascript: ${r.stderr.slice(-200)}` };
377
+ return { ok: true };
378
+ };
379
+ // Pane fingerprint for verifying paste/submit. `rows` controls how far back
380
+ // from the bottom to capture: a wide window (12) for paste-landed (lenient,
381
+ // catches wrapped content / hint lines), a narrow window (5) for input-box-
382
+ // cleared. The narrow window matters after `/clear`: the buffer is almost
383
+ // empty, so claude's echo of the just-submitted message sits directly above
384
+ // the input box and would otherwise re-trigger the fingerprint, falsely
385
+ // flagging "Enter not honored".
386
+ const capturePaneTail = async (target, rows = 12) => {
387
+ const r = await tmuxRun(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
388
+ return r.ok ? r.stdout : "";
389
+ };
390
+ // Two fingerprints, derived from different ends of `text`:
391
+ // headFp — first 8 non-ws chars; used for "did paste land" against a wide
392
+ // window because long pastes wrap and the head sits near the top of the
393
+ // input box, possibly outside a tight tail capture.
394
+ // tailFp — last 8 non-ws chars; sits right above the cursor (bottom of the
395
+ // input box). Used for "did input box clear" against a narrow window:
396
+ // after Enter, claude echoes the user message ABOVE the input box, so a
397
+ // wide capture stays "dirty" forever; a tight 5-row capture sees only
398
+ // the input box itself, which DOES clear.
399
+ const fingerprints = (text) => {
400
+ const stripped = text.replace(/\s+/gu, "").trim();
401
+ return { headFp: stripped.slice(0, 8), tailFp: stripped.slice(-8) };
402
+ };
403
+ // Self-verifying inject. The cold-spawn race we guard against: paste lands
404
+ // but the trailing Enter is eaten while the TUI is still initializing, so
405
+ // the prompt sits typed-but-unsent. Strategy:
406
+ // 1. paste; wide-window poll for headFp → paste reached the input box.
407
+ // 2. settle; send Enter.
408
+ // 3. narrow-window poll (last 5 rows = just the input box, NOT the echo
409
+ // above) for tailFp absence → submit was honored.
410
+ // 4. on stuck-after-Enter, retry Enter once with extra settle.
411
+ const injectViaTmux = async (target, text, images, log, freshSpawn) => {
412
+ log.info({ target, len: text.length, images: images.length, freshSpawn }, "mirror inject (tmux)");
413
+ // Pump images first via clipboard+C-v so each one is attached as a separate
414
+ // image content block. Each C-v needs a brief settle for Claude Code's TUI
415
+ // to read the clipboard before the next overwrite. Fresh spawn extends.
416
+ const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
417
+ for (const imgPath of images) {
418
+ const cb = await setMacClipboardImage(imgPath);
419
+ if (!cb.ok) {
420
+ log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
421
+ continue;
422
+ }
423
+ const cv = await tmuxRun(["send-keys", "-t", target, "C-v"]);
424
+ if (!cv.ok)
425
+ return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
426
+ await sleepMs(IMG_SETTLE_MS);
427
+ }
428
+ // Image-only message: TUI input box now holds the attached images; press
429
+ // Enter and we're done. No fingerprint to verify (the input itself is binary
430
+ // image refs, not text).
431
+ if (!text) {
432
+ if (images.length === 0)
433
+ return { ok: true };
434
+ const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
435
+ return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
436
+ }
437
+ // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
438
+ // started, transcript still loading): extended timings — bracketed-paste
439
+ // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
440
+ // pays the latency cost.
441
+ const PASTE_VERIFY_MS = freshSpawn ? 6000 : 2500;
442
+ const POST_PASTE_SETTLE_MS = freshSpawn ? 1500 : 400;
443
+ const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
444
+ const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
445
+ const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
446
+ const loadAndPaste = async () => {
447
+ const loaded = await new Promise((resolve) => {
448
+ const loader = spawn("tmux", ["load-buffer", "-"], { stdio: ["pipe", "ignore", "pipe"] });
449
+ let lerr = "";
450
+ loader.stderr?.on("data", (c) => (lerr += c.toString("utf8")));
451
+ loader.on("error", (e) => resolve({ ok: false, reason: `tmux not found: ${e.message}` }));
452
+ loader.on("close", (code) => {
453
+ if (code !== 0)
454
+ resolve({ ok: false, reason: `tmux load-buffer exit ${code}: ${lerr.slice(-200)}` });
455
+ else
456
+ resolve({ ok: true });
266
457
  });
458
+ loader.stdin?.end(text);
267
459
  });
268
- loader.stdin?.end(text);
269
- });
460
+ if (!loaded.ok)
461
+ return loaded;
462
+ const pasted = await tmuxRun(["paste-buffer", "-p", "-d", "-t", target]);
463
+ if (!pasted.ok)
464
+ return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
465
+ return { ok: true };
466
+ };
467
+ let r = await loadAndPaste();
468
+ if (!r.ok)
469
+ return r;
470
+ const { headFp, tailFp } = fingerprints(text);
471
+ const POLL_MS = 100;
472
+ const stripWs = (s) => s.replace(/\s+/gu, "");
473
+ // Wide window catches a wrapped paste's head whether it's row -3 or row -10.
474
+ const sawHead = async (timeoutMs) => {
475
+ const t0 = Date.now();
476
+ while (Date.now() - t0 < timeoutMs) {
477
+ const pane = await capturePaneTail(target, 12);
478
+ if (headFp && stripWs(pane).includes(headFp))
479
+ return true;
480
+ await sleepMs(POLL_MS);
481
+ }
482
+ return false;
483
+ };
484
+ // Narrow window = just the input box. tailFp is the chars next to the
485
+ // cursor, so it's always inside this window pre-submit and gone post-submit.
486
+ const inputBoxStillHasTail = async () => {
487
+ const pane = await capturePaneTail(target, 5);
488
+ return Boolean(tailFp) && stripWs(pane).includes(tailFp);
489
+ };
490
+ let pasteSeen = await sawHead(PASTE_VERIFY_MS);
491
+ if (!pasteSeen) {
492
+ // Paste fired before the TUI was reading — re-paste once after a back-off.
493
+ log.warn({ target, headFp }, "mirror inject: paste headFp not seen, re-pasting");
494
+ await sleepMs(RETRY_SETTLE_MS);
495
+ r = await loadAndPaste();
496
+ if (!r.ok)
497
+ return r;
498
+ pasteSeen = await sawHead(PASTE_VERIFY_MS);
499
+ }
500
+ // Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
501
+ // Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
502
+ await sleepMs(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
503
+ const sendEnter = async () => {
504
+ const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
505
+ return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
506
+ };
507
+ const waitForCleared = async (timeoutMs) => {
508
+ const t0 = Date.now();
509
+ while (Date.now() - t0 < timeoutMs) {
510
+ await sleepMs(POLL_MS);
511
+ if (!(await inputBoxStillHasTail()))
512
+ return true;
513
+ }
514
+ return false;
515
+ };
516
+ const e1 = await sendEnter();
517
+ if (!e1.ok)
518
+ return e1;
519
+ if (!pasteSeen)
520
+ log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
521
+ if (!tailFp)
522
+ return { ok: true }; // empty/whitespace text — nothing to verify
523
+ if (await waitForCleared(CLEARED_TIMEOUT_MS))
524
+ return { ok: true };
525
+ // Input box still holds our tail — Enter was eaten (cold TUI) or the
526
+ // bracketed-paste end hadn't been processed yet. One retry with extra
527
+ // settle. We keep this to ONE retry to bound damage if our cleared-check
528
+ // is wrong (would otherwise spam Enters into a real conversation).
529
+ log.warn({ target, tailFp }, "mirror inject: input box still has text after Enter, retrying once");
530
+ await sleepMs(RETRY_SETTLE_MS);
531
+ const e2 = await sendEnter();
532
+ if (!e2.ok)
533
+ return e2;
534
+ if (await waitForCleared(CLEARED_TIMEOUT_MS))
535
+ return { ok: true };
536
+ // freshSpawn fallback: claude --resume can take longer than our budget to
537
+ // process bracketed-paste end. The Enter was sent twice; if it lands later,
538
+ // claude will process the prompt and the user gets their reply. Trust it
539
+ // rather than reporting a hard failure that the user actually got served.
540
+ //
541
+ // Warm-pane path also trusts: in practice tmux Enter is reliable once the
542
+ // pane exists, and the verifier has structural false-positive risk —
543
+ // tailFp (last 8 non-ws chars) can match the echo line directly above the
544
+ // input box when it falls within the 5-row capture window. Surfacing
545
+ // `[mirror] ✗` to the user when the prompt actually landed is worse than
546
+ // accepting an extra no-op Enter on the rare true-stuck case.
547
+ log.warn({ target, tailFp, freshSpawn }, "mirror inject: clear not observed, trusting submit");
548
+ return { ok: true };
270
549
  };
271
550
  const injectViaSpawn = (args) => {
272
- const { text, cfg, log, sessionId } = args;
551
+ const { text, images = [], cfg, log, sessionId } = args;
552
+ // Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
553
+ // which Claude parses at submit time and inlines as image content blocks
554
+ // without a model-decided Read tool turn.
555
+ const refs = images.map((p) => `@${p}`).join("\n");
556
+ const finalText = refs ? (text ? `${refs}\n${text}` : refs) : text;
273
557
  const cliArgs = [
274
558
  "-p",
275
- text,
559
+ finalText,
276
560
  "--resume",
277
561
  sessionId,
278
562
  "--output-format",
@@ -310,9 +594,10 @@ const injectViaSpawn = (args) => {
310
594
  });
311
595
  });
312
596
  };
313
- const inject = (args) => args.cfg.wrc.mirror.tmuxTarget.trim()
314
- ? injectViaTmux(args.cfg.wrc.mirror.tmuxTarget.trim(), args.text, args.log)
315
- : injectViaSpawn(args);
597
+ const inject = (args) => {
598
+ const target = (args.tmuxTarget ?? "").trim();
599
+ return target ? injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false) : injectViaSpawn(args);
600
+ };
316
601
  const queues = new Map();
317
602
  const enqueue = (key, job) => {
318
603
  const prev = queues.get(key) ?? Promise.resolve();
@@ -325,7 +610,11 @@ const enqueue = (key, job) => {
325
610
  };
326
611
  export const startMirror = (deps) => {
327
612
  const { cfg, log, client } = deps;
328
- let state;
613
+ // Multi-mirror: each (sessionId, target) pair is one Attachment. Same
614
+ // sessionId reattaches → replace. Same target with different sessionId →
615
+ // replace too (one WeCom chat can only show one mirror at a time).
616
+ const bySessionId = new Map();
617
+ const byTarget = new Map();
329
618
  // Ring buffer of recently-injected user texts to suppress WeCom→CLI echo.
330
619
  const INJECT_TTL_MS = 60_000;
331
620
  const recentInjects = [];
@@ -334,6 +623,15 @@ export const startMirror = (deps) => {
334
623
  if (!t)
335
624
  return;
336
625
  recentInjects.push({ text: t, ts: Date.now() });
626
+ // Slash commands land in the jsonl wrapped in <command-name>...</command-name>
627
+ // tags; cleanUserText renders that as `/cmd args` (backticked). Push that
628
+ // form too so the dedupe filter catches the tail's emission — without this,
629
+ // /clear (and any other slash inject) echoes back as a quoted bubble.
630
+ if (t.startsWith("/")) {
631
+ const head = t.split(/\s+/, 1)[0] ?? t;
632
+ const args = t.slice(head.length).trim();
633
+ recentInjects.push({ text: `\`${head}${args ? ` ${args}` : ""}\``, ts: Date.now() });
634
+ }
337
635
  if (recentInjects.length > 64)
338
636
  recentInjects.shift();
339
637
  };
@@ -350,33 +648,58 @@ export const startMirror = (deps) => {
350
648
  return false;
351
649
  };
352
650
  // ── Typewriter stream lifecycle ────────────────────────────────────
353
- // For a WeCom→Claude turn we hold the inbound frame and keep updating ONE
354
- // markdown via replyStream as transcript items arrive. Tool calls + text
355
- // accumulate into a single growing message. Idle timer finalizes the turn
356
- // (finish=true) when no new content arrives for IDLE_END_MS.
651
+ // WeCom spec: server polls us for stream refreshes for up to 6 min from the
652
+ // original inbound. SDK queues replyStream calls per req_id, sends serially
653
+ // (5s ack timeout each). After 6 min the server stops accepting refreshes.
654
+ // We keep the stream open until either (a) the next inbound supersedes it,
655
+ // (b) the hard cap fires (just under 6 min), (c) server rejects (s.dead),
656
+ // or (d) we hit the byte cap. No idle-based close — claude can sit thinking
657
+ // for >60s mid-turn and we don't want to drop the bubble while it's chewing.
357
658
  const FLUSH_MS = 250;
358
- const IDLE_END_MS = 30_000;
359
- const STREAM_SOFT_CAP = 18_000; // WeCom stream content limit ~20480 bytes
360
- let injectStream;
659
+ const HARD_TIMEOUT_MS = 350_000;
660
+ const STREAM_SOFT_CAP = 18_000;
661
+ const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
662
+ const turnRegistry = new Map();
663
+ const evictTurns = () => {
664
+ const now = Date.now();
665
+ for (const [k, v] of turnRegistry)
666
+ if (v.expiresAt < now)
667
+ turnRegistry.delete(k);
668
+ };
669
+ const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
670
+ const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
671
+ const detailCardFor = (s) => {
672
+ if (s.tools.length === 0)
673
+ return undefined;
674
+ return {
675
+ card_type: "button_interaction",
676
+ main_title: { title: "本轮工具调用" },
677
+ sub_title_text: `共 ${s.tools.length} 次调用,点击查看详情`,
678
+ task_id: s.turnId,
679
+ button_list: [{ text: "查看详情", style: 1, key: `${TOOL_DETAIL_PREFIX}${s.turnId}` }],
680
+ };
681
+ };
361
682
  const flushStream = async (s) => {
362
683
  s.flushTimer = undefined;
363
- if (s.closed || s.acc === s.lastSent)
684
+ if (s.closed || s.dead || s.acc === s.lastSent)
364
685
  return;
365
686
  const content = s.acc;
366
687
  s.lastSent = content;
367
688
  try {
368
689
  await client.replyStream(s.frame, s.streamId, content || " ", false);
690
+ log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
369
691
  }
370
692
  catch (e) {
371
- log.warn({ err: e.message }, "stream flush failed");
693
+ log.warn({ turnId: s.turnId, err: e.message }, "stream flush failed; marking dead");
694
+ s.dead = true;
372
695
  }
373
696
  };
374
697
  const scheduleFlush = (s) => {
375
- if (s.flushTimer || s.closed)
698
+ if (s.flushTimer || s.closed || s.dead)
376
699
  return;
377
700
  s.flushTimer = setTimeout(() => void flushStream(s), FLUSH_MS);
378
701
  };
379
- const closeStream = async (s) => {
702
+ const finalizeStream = async (a, s) => {
380
703
  if (s.closed)
381
704
  return;
382
705
  s.closed = true;
@@ -388,152 +711,650 @@ export const startMirror = (deps) => {
388
711
  clearTimeout(s.idleTimer);
389
712
  s.idleTimer = undefined;
390
713
  }
391
- try {
392
- await client.replyStream(s.frame, s.streamId, s.acc || " ", true);
714
+ if (s.hardTimer) {
715
+ clearTimeout(s.hardTimer);
716
+ s.hardTimer = undefined;
393
717
  }
394
- catch (e) {
395
- log.warn({ err: e.message }, "stream finish failed");
718
+ if (!s.dead) {
719
+ const card = detailCardFor(s);
720
+ try {
721
+ if (card) {
722
+ s.cardSent = true;
723
+ await client.replyStreamWithCard(s.frame, s.streamId, s.acc || " ", true, { templateCard: card });
724
+ }
725
+ else {
726
+ await client.replyStream(s.frame, s.streamId, s.acc || " ", true);
727
+ }
728
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
729
+ }
730
+ catch (e) {
731
+ log.warn({ sessionId: a.sessionId, turnId: s.turnId, err: e.message }, "stream finalize failed");
732
+ s.dead = true;
733
+ }
396
734
  }
397
- s.closedResolve();
398
- };
399
- const resetIdle = (s) => {
400
- if (s.idleTimer)
401
- clearTimeout(s.idleTimer);
402
- s.idleTimer = setTimeout(() => void closeStream(s), IDLE_END_MS);
735
+ else {
736
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length }, "stream finalize (dead)");
737
+ }
738
+ if (s.tools.length > 0) {
739
+ turnRegistry.set(s.turnId, {
740
+ tools: s.tools,
741
+ target: a.target,
742
+ expiresAt: Date.now() + TOOL_DETAIL_TTL_MS,
743
+ });
744
+ evictTurns();
745
+ }
746
+ if (a.liveStream === s)
747
+ a.liveStream = undefined;
403
748
  };
404
- const openStream = (frame, streamId) => {
405
- let resolve;
406
- const closedPromise = new Promise((r) => { resolve = r; });
749
+ const openStream = (a, frame, streamId) => {
407
750
  const s = {
751
+ turnId: newTurnId(),
408
752
  frame, streamId,
409
753
  acc: "", lastSent: "",
410
- capped: false, closed: false,
411
- closedResolve: resolve, closedPromise,
754
+ capped: false, closed: false, dead: false, cardSent: false,
755
+ tools: [],
412
756
  };
757
+ s.hardTimer = setTimeout(() => void finalizeStream(a, s), HARD_TIMEOUT_MS);
758
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, streamId }, "stream open");
413
759
  return s;
414
760
  };
415
- // Standalone fallback (no active stream TTY-origin content). Per-item
416
- // markdown push, serialized to keep order.
417
- let standalonePending = Promise.resolve();
418
- const sendStandalone = (content) => {
419
- if (!state)
420
- return;
421
- const chatId = stripPrincipalPrefix(state.target);
761
+ // Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
762
+ // pushes from a single mirror stay ordered; different mirrors run in parallel.
763
+ const sendStandalone = (a, content) => {
764
+ const chatId = stripPrincipalPrefix(a.target);
422
765
  const chunks = splitChunks(content, cfg.wrc.mirror.chunkChars);
423
- standalonePending = standalonePending
766
+ a.standalonePending = a.standalonePending
424
767
  .then(async () => {
425
768
  for (const c of chunks) {
426
769
  try {
427
770
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
428
771
  }
429
772
  catch (e) {
430
- log.warn({ err: e.message }, "standalone push failed");
773
+ log.warn({ sessionId: a.sessionId, err: e.message }, "standalone push failed");
431
774
  }
432
775
  }
433
776
  })
434
777
  .catch(() => undefined);
435
778
  };
436
- const onItem = (item) => {
437
- const s = injectStream;
438
- if (s && !s.closed) {
439
- if (s.capped)
440
- return;
441
- const sep = s.acc ? "\n\n---\n\n" : "";
779
+ // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 合并成单条 markdown。
780
+ // 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
781
+ const flushStandalone = (a) => {
782
+ const buf = a.standaloneBuf;
783
+ if (!buf)
784
+ return;
785
+ a.standaloneBuf = undefined;
786
+ sendStandalone(a, buf.parts.join("\n\n"));
787
+ };
788
+ const enqueueStandalone = (a, content) => {
789
+ const ms = cfg.wrc.mirror.standaloneDebounceMs;
790
+ if (ms <= 0) {
791
+ sendStandalone(a, content);
792
+ return;
793
+ }
794
+ if (a.standaloneBuf) {
795
+ clearTimeout(a.standaloneBuf.timer);
796
+ a.standaloneBuf.parts.push(content);
797
+ a.standaloneBuf.timer = setTimeout(() => flushStandalone(a), ms);
798
+ return;
799
+ }
800
+ a.standaloneBuf = {
801
+ parts: [content],
802
+ timer: setTimeout(() => flushStandalone(a), ms),
803
+ };
804
+ };
805
+ const recordToolEntry = (s, item) => {
806
+ if (item.kind === "tool_use") {
807
+ s.tools.push({ toolUseId: item.toolUseId, name: item.name, input: item.input });
808
+ }
809
+ else if (item.kind === "tool_result") {
810
+ // Match by toolUseId; if not found, append a standalone result entry.
811
+ const existing = item.toolUseId
812
+ ? s.tools.find((t) => t.toolUseId === item.toolUseId && t.result === undefined)
813
+ : undefined;
814
+ if (existing)
815
+ existing.result = item.full;
816
+ else
817
+ s.tools.push({ toolUseId: item.toolUseId, name: "(result)", input: undefined, result: item.full });
818
+ }
819
+ };
820
+ const onItem = (a, item) => {
821
+ // CLI-side user line marks a turn boundary that did NOT come from WeCom.
822
+ // Close any open WeCom liveStream first so the new conversation gets its
823
+ // own bubble — otherwise the user's CLI exchange silently mutates the
824
+ // previous WeCom bubble (still within its 6min update window) and is
825
+ // invisible on the chat side.
826
+ if (item.kind === "user_text" && a.liveStream && !a.liveStream.closed) {
827
+ void finalizeStream(a, a.liveStream);
828
+ }
829
+ const s = a.liveStream;
830
+ if (s && !s.closed && !s.dead && !s.capped) {
831
+ const sep = s.acc ? "\n\n" : "";
442
832
  const next = s.acc + sep + item.body;
443
833
  if (next.length > STREAM_SOFT_CAP) {
444
- s.acc = `${s.acc}${sep}…(truncated, see Claude TTY for full output)`;
834
+ s.acc = `${s.acc}${sep}…(超出 stream 容量上限,详情见"查看详情")`;
445
835
  s.capped = true;
446
836
  }
447
837
  else {
448
838
  s.acc = next;
449
839
  }
450
- resetIdle(s);
840
+ if (item.kind !== "text")
841
+ recordToolEntry(s, item);
842
+ log.debug({ sessionId: a.sessionId, turnId: s.turnId, kind: item.kind, accLen: s.acc.length }, "stream append");
451
843
  scheduleFlush(s);
452
844
  return;
453
845
  }
454
- sendStandalone(item.body);
846
+ log.info({ sessionId: a.sessionId, kind: item.kind, hasLive: !!s, closed: s?.closed, dead: s?.dead, capped: s?.capped }, "fallback standalone");
847
+ enqueueStandalone(a, item.body);
848
+ };
849
+ const resolveTarget = (override) => sanitizeId(override?.trim() || cfg.wrc.mirror.pushChat.trim() || cfg.defaultChat.trim());
850
+ // Detach an attachment: finalize any live stream, stop the tail, drop from indexes.
851
+ const detach = (a, reason) => {
852
+ if (a.migrationWatcher) {
853
+ a.migrationWatcher.cancel();
854
+ a.migrationWatcher = undefined;
855
+ }
856
+ if (a.liveStream && !a.liveStream.closed)
857
+ void finalizeStream(a, a.liveStream);
858
+ if (a.standaloneBuf) {
859
+ clearTimeout(a.standaloneBuf.timer);
860
+ flushStandalone(a);
861
+ }
862
+ a.tail.stop();
863
+ bySessionId.delete(a.sessionId);
864
+ if (byTarget.get(a.target) === a)
865
+ byTarget.delete(a.target);
866
+ log.info({ sessionId: a.sessionId, target: a.target, reason }, "mirror detached");
455
867
  };
456
- const resolveTarget = (override) => (override?.trim() || cfg.wrc.mirror.pushChat.trim() || cfg.defaultChat.trim());
457
- const attach = ({ sessionId, jsonlPath, target: targetOverride }) => {
868
+ // Click-to-detail URL for a tool_use id. Cached on the bridge so both
869
+ // attach() and migrateAttachment() share the same closure. Returns ""
870
+ // when disabled in config — renderLine then drops the markdown wrapping.
871
+ const detailUrlFor = (id) => cfg.daemon.detailLinksInMirror && id
872
+ ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id)
873
+ : "";
874
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession }) => {
458
875
  const target = resolveTarget(targetOverride);
459
876
  if (!target)
460
877
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
461
- if (!existsSync(jsonlPath))
462
- return { ok: false, reason: `jsonl missing: ${jsonlPath}` };
463
- state?.tail.stop();
464
- const tail = startMirrorTail({
878
+ // Note: jsonlPath may not exist yet on the auto-spawn path — claude only
879
+ // creates its transcript after the first user input. The tail tolerates a
880
+ // missing file (existsSync gate at start, try/catch in drain) and the 1s
881
+ // poll picks it up the moment claude writes the first line.
882
+ // Replace any existing attach with the same sessionId or same target. The
883
+ // sessionId clash is the "/wrc again from same window" case; the target
884
+ // clash is "different window steals my WeCom chat" — both end the previous.
885
+ const prevBySid = bySessionId.get(sessionId);
886
+ if (prevBySid)
887
+ detach(prevBySid, "sessionId reattach");
888
+ const prevByTarget = byTarget.get(target);
889
+ if (prevByTarget)
890
+ detach(prevByTarget, "target reassigned");
891
+ // Build the attachment first so the tail's onItem closure can capture it.
892
+ const a = {
893
+ sessionId,
465
894
  jsonlPath,
466
- log: log.child({ sub: "tail" }),
895
+ target,
896
+ tmuxPane: (tmuxPane ?? "").trim(),
897
+ tmuxSession: (tmuxSession ?? "").trim(),
898
+ tail: { stop: () => undefined }, // placeholder; replaced below
899
+ standalonePending: Promise.resolve(),
900
+ };
901
+ a.tail = startMirrorTail({
902
+ jsonlPath,
903
+ log: log.child({ sub: "tail", sessionId }),
467
904
  includeUser: cfg.wrc.mirror.includeUser,
468
905
  includeTools: cfg.wrc.mirror.includeTools,
469
906
  includeToolResults: cfg.wrc.mirror.includeToolResults,
470
907
  toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
471
908
  isOwnInject,
472
- onItem,
909
+ onItem: (item) => onItem(a, item),
910
+ detailUrlFor,
911
+ sessionId,
912
+ target,
473
913
  });
474
- state = { sessionId, jsonlPath, target, tail };
475
- log.info({ sessionId, jsonlPath, target }, "mirror attached");
914
+ bySessionId.set(sessionId, a);
915
+ byTarget.set(target, a);
916
+ deps.store.set(target, {
917
+ sessionId,
918
+ jsonlPath,
919
+ tmuxSession: a.tmuxSession || undefined,
920
+ tmuxPane: a.tmuxPane || undefined,
921
+ });
922
+ log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, mirrors: bySessionId.size }, "mirror attached");
476
923
  return { ok: true, sessionId, jsonlPath, target };
477
924
  };
478
- // Best-effort: if config can resolve a session at boot, auto-attach.
479
- const resolved = resolveSession(cfg, log);
480
- if (resolved) {
481
- const r = attach({ sessionId: resolved.sessionId, jsonlPath: resolved.jsonlPath });
482
- if (!r.ok)
483
- log.warn({ reason: r.reason }, "mirror: auto-attach skipped");
925
+ // ── Persistence: restore-from-store (lazy + boot) ────────────────────
926
+ const tmuxRun = (args) => new Promise((resolve) => {
927
+ const p = spawn("tmux", args, {
928
+ env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
929
+ stdio: ["ignore", "pipe", "ignore"],
930
+ });
931
+ let out = "";
932
+ p.stdout?.on("data", (c) => (out += c.toString("utf8")));
933
+ p.on("error", () => resolve({ code: null, stdout: "" }));
934
+ p.on("close", (code) => resolve({ code, stdout: out }));
935
+ });
936
+ // Verify a paneId still exists. `display-message -t <paneId>` succeeds iff
937
+ // the pane is alive — and tmux pane ids monotonically increment within a
938
+ // server lifetime, so a freed id will not be silently reused. We don't need
939
+ // the session name, which matters because /wrc attaches capture only $TMUX_PANE.
940
+ const tmuxPaneAlive = async (paneId) => {
941
+ if (!paneId)
942
+ return false;
943
+ const r = await tmuxRun(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
944
+ return r.code === 0 && r.stdout.trim() === paneId;
945
+ };
946
+ // Re-attach a stored binding for `principal`. Returns the resulting state, or
947
+ // undefined if the on-disk transcript is gone (in which case the entry is
948
+ // dropped so the next inbound flows through /new auto-spawn).
949
+ const restoreFromStore = async (principal) => {
950
+ const rec = deps.store.get(principal);
951
+ if (!rec)
952
+ return undefined;
953
+ const jsonlAbs = expandHome(rec.jsonlPath);
954
+ if (!existsSync(jsonlAbs)) {
955
+ log.warn({ principal, jsonlPath: rec.jsonlPath }, "mirror restore: jsonl missing, dropping entry");
956
+ deps.store.drop(principal);
957
+ return undefined;
958
+ }
959
+ // Prefer the stored pane id and validate via display-message. Pane ids
960
+ // (`%N`) are monotonic per tmux server lifetime, so they're stable across
961
+ // daemon reloads as long as the tmux server didn't restart. With the
962
+ // shared `weclaude` session hosting many chats, listing panes by session
963
+ // name would mis-route to whichever window happens to be first — never
964
+ // do that. If the stored pane is dead, leave tmuxPane empty and let
965
+ // dispatch's respawn check reincarnate via `claude --resume <sid>`.
966
+ const storedPane = (rec.tmuxPane ?? "").trim();
967
+ const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
968
+ const r = attach({
969
+ sessionId: rec.sessionId,
970
+ jsonlPath: jsonlAbs,
971
+ target: principal,
972
+ tmuxPane: livePane,
973
+ tmuxSession: rec.tmuxSession ?? "",
974
+ });
975
+ if (!r.ok) {
976
+ log.warn({ principal, reason: r.reason }, "mirror restore: re-attach failed");
977
+ return undefined;
978
+ }
979
+ log.info({ principal, sessionId: rec.sessionId, livePane: livePane || "(spawn-mode)" }, "mirror restored from store");
980
+ return byTarget.get(principal);
981
+ };
982
+ // We re-attach lazily on demand too (see restoreFromStore in dispatch /
983
+ // hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
984
+ // even before any inbound arrives — e.g. claude finishes a long-running
985
+ // background task and writes assistant content to the jsonl while idle.
986
+ const persisted = deps.store.all();
987
+ const persistedKeys = Object.keys(persisted);
988
+ if (persistedKeys.length > 0) {
989
+ for (const principal of persistedKeys) {
990
+ void restoreFromStore(principal);
991
+ }
992
+ }
993
+ else if (cfg.wrc.mirror.sessionId.trim()) {
994
+ // Pinned-sessionId fallback: only honor when the store is empty (otherwise
995
+ // the persisted bindings already cover the right sessions).
996
+ const resolved = resolveSession(cfg, log);
997
+ if (resolved) {
998
+ const r = attach({ sessionId: resolved.sessionId, jsonlPath: resolved.jsonlPath });
999
+ if (!r.ok)
1000
+ log.warn({ reason: r.reason }, "mirror: pinned auto-attach skipped");
1001
+ }
484
1002
  }
485
1003
  else {
486
- log.warn("mirror: no session resolved at boot — waiting for /mirror/attach");
1004
+ log.info("mirror: no persisted attachments and no pinned sessionId — waiting for /new or /mirror/attach");
487
1005
  }
1006
+ // Render full tool details for a finalized turn into one-or-more markdown
1007
+ // chunks (split at chunkChars boundaries).
1008
+ const renderToolDetails = (tools) => {
1009
+ const parts = [];
1010
+ let i = 0;
1011
+ for (const t of tools) {
1012
+ i += 1;
1013
+ const inputJson = t.input === undefined
1014
+ ? "(no input)"
1015
+ : (() => {
1016
+ try {
1017
+ return JSON.stringify(t.input, null, 2);
1018
+ }
1019
+ catch {
1020
+ return "(unrenderable)";
1021
+ }
1022
+ })();
1023
+ const result = t.result ?? "(no result captured)";
1024
+ parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncate(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncate(result, 4000)}\n\`\`\``);
1025
+ }
1026
+ const merged = parts.join("\n\n---\n\n");
1027
+ return splitChunks(merged, cfg.wrc.mirror.chunkChars);
1028
+ };
1029
+ const resolveToolDetail = (turnId) => {
1030
+ evictTurns();
1031
+ const r = turnRegistry.get(turnId);
1032
+ if (!r)
1033
+ return undefined;
1034
+ return { target: r.target, markdown: renderToolDetails(r.tools) };
1035
+ };
1036
+ // ── /clear → session migration ──────────────────────────────────────
1037
+ // After `/clear` claude rotates to a fresh sessionId on the very next user
1038
+ // input. Detect the rotation by watching the project dir for a new .jsonl
1039
+ // that didn't exist at /clear time and contains a non-empty user line, then
1040
+ // re-target this attachment onto it.
1041
+ const isClearCommand = (text) => {
1042
+ const t = text.trim();
1043
+ return t === "/clear" || t.startsWith("/clear ") || t.startsWith("/clear\n");
1044
+ };
1045
+ const listJsonls = (dir) => {
1046
+ try {
1047
+ return new Set(readdirSync(dir).filter((n) => n.endsWith(".jsonl")));
1048
+ }
1049
+ catch {
1050
+ return new Set();
1051
+ }
1052
+ };
1053
+ // Post-/clear identification: claude rotates the session IMMEDIATELY on
1054
+ // /clear (not on next user input as the original design assumed) and writes
1055
+ // the `/clear` command itself as the first non-meta user line of the brand-
1056
+ // new jsonl. Match that signature to (a) confirm the file is a /clear-rotated
1057
+ // child, (b) avoid mis-migrating onto an unrelated jsonl that some other
1058
+ // claude process happened to create on the same cwd in our window.
1059
+ const SLASH_CLEAR_USER_RE = /<command-name>\s*\/clear\s*<\/command-name>/;
1060
+ const jsonlIsPostClearChild = (path) => {
1061
+ try {
1062
+ const fd = openSync(path, "r");
1063
+ const size = statSync(path).size;
1064
+ const cap = Math.min(size, 64 * 1024);
1065
+ const buf = Buffer.alloc(cap);
1066
+ readSync(fd, buf, 0, cap, 0);
1067
+ closeSync(fd);
1068
+ const text = buf.toString("utf8");
1069
+ for (const line of text.split("\n")) {
1070
+ if (!line.trim())
1071
+ continue;
1072
+ try {
1073
+ const j = JSON.parse(line);
1074
+ if (j.type !== "user" || j.isMeta || j.isSidechain)
1075
+ continue;
1076
+ const c = j.message?.content;
1077
+ // First non-meta user line decides: /clear → match; anything else → reject.
1078
+ return typeof c === "string" && SLASH_CLEAR_USER_RE.test(c);
1079
+ }
1080
+ catch { /* partial line */ }
1081
+ }
1082
+ }
1083
+ catch { /* unreadable */ }
1084
+ return false;
1085
+ };
1086
+ const migrateAttachment = (a, newSessionId, newJsonlPath) => {
1087
+ const oldSessionId = a.sessionId;
1088
+ const oldJsonlPath = a.jsonlPath;
1089
+ a.tail.stop();
1090
+ bySessionId.delete(oldSessionId);
1091
+ a.sessionId = newSessionId;
1092
+ a.jsonlPath = newJsonlPath;
1093
+ bySessionId.set(newSessionId, a);
1094
+ a.tail = startMirrorTail({
1095
+ jsonlPath: newJsonlPath,
1096
+ log: log.child({ sub: "tail", sessionId: newSessionId }),
1097
+ includeUser: cfg.wrc.mirror.includeUser,
1098
+ includeTools: cfg.wrc.mirror.includeTools,
1099
+ includeToolResults: cfg.wrc.mirror.includeToolResults,
1100
+ toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
1101
+ isOwnInject,
1102
+ onItem: (item) => onItem(a, item),
1103
+ detailUrlFor,
1104
+ sessionId: newSessionId,
1105
+ target: a.target,
1106
+ // Replay from the very start: by the time we migrate, the new jsonl
1107
+ // already holds the rotated user line and possibly early assistant
1108
+ // lines. Without this the tail would start at EOF and silently drop
1109
+ // them, leaving the WeCom stream stuck at "…".
1110
+ startOffset: 0,
1111
+ });
1112
+ deps.store.set(a.target, {
1113
+ sessionId: newSessionId,
1114
+ jsonlPath: newJsonlPath,
1115
+ tmuxSession: a.tmuxSession || undefined,
1116
+ tmuxPane: a.tmuxPane || undefined,
1117
+ });
1118
+ log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath }, "mirror migrated post-/clear");
1119
+ };
1120
+ const startMigrationWatcher = (a, baseline) => {
1121
+ if (a.migrationWatcher)
1122
+ a.migrationWatcher.cancel(); // /clear sent twice in a row
1123
+ const projectDir = dirname(a.jsonlPath);
1124
+ // baseline is captured by the caller BEFORE inject runs — claude creates
1125
+ // the rotated jsonl synchronously while processing /clear, so a baseline
1126
+ // taken here (post-inject) would already include it and migration would
1127
+ // never fire.
1128
+ const POLL_MS = 500;
1129
+ const TIMEOUT_MS = 5 * 60_000; // generous: user may take a while to type
1130
+ const t0 = Date.now();
1131
+ let stopped = false;
1132
+ let timer;
1133
+ const tick = () => {
1134
+ if (stopped)
1135
+ return;
1136
+ if (Date.now() - t0 > TIMEOUT_MS) {
1137
+ log.warn({ target: a.target, sessionId: a.sessionId }, "mirror /clear migration: timeout, giving up");
1138
+ a.migrationWatcher = undefined;
1139
+ return;
1140
+ }
1141
+ const current = listJsonls(projectDir);
1142
+ const candidates = [];
1143
+ for (const name of current)
1144
+ if (!baseline.has(name))
1145
+ candidates.push(name);
1146
+ // Pick newest-mtime candidate that has user content. Older candidates
1147
+ // without content stay in the running until they accrue — never aborts.
1148
+ const ranked = candidates
1149
+ .map((n) => ({ n, mtime: (() => { try {
1150
+ return statSync(join(projectDir, n)).mtimeMs;
1151
+ }
1152
+ catch {
1153
+ return 0;
1154
+ } })() }))
1155
+ .sort((x, y) => y.mtime - x.mtime);
1156
+ for (const c of ranked) {
1157
+ const p = join(projectDir, c.n);
1158
+ if (jsonlIsPostClearChild(p)) {
1159
+ stopped = true;
1160
+ a.migrationWatcher = undefined;
1161
+ const newSid = c.n.replace(/\.jsonl$/, "");
1162
+ if (newSid !== a.sessionId)
1163
+ migrateAttachment(a, newSid, p);
1164
+ return;
1165
+ }
1166
+ }
1167
+ timer = setTimeout(tick, POLL_MS);
1168
+ };
1169
+ a.migrationWatcher = {
1170
+ cancel: () => { stopped = true; if (timer)
1171
+ clearTimeout(timer); },
1172
+ };
1173
+ log.info({ target: a.target, sessionId: a.sessionId, projectDir, baselineCount: baseline.size }, "mirror /clear migration: watcher armed");
1174
+ timer = setTimeout(tick, POLL_MS);
1175
+ };
488
1176
  return {
489
1177
  attach,
490
- status: () => state
491
- ? { attached: true, sessionId: state.sessionId, jsonlPath: state.jsonlPath, target: state.target }
492
- : { attached: false },
1178
+ status: () => {
1179
+ const list = Array.from(bySessionId.values()).map((a) => ({
1180
+ sessionId: a.sessionId,
1181
+ jsonlPath: a.jsonlPath,
1182
+ target: a.target,
1183
+ }));
1184
+ // Keep a single-attach view for back-compat; first entry wins when there's
1185
+ // exactly one mirror, callers iterating `mirrors` get the full picture.
1186
+ const first = list[0];
1187
+ return first
1188
+ ? { attached: true, mirrors: list, sessionId: first.sessionId, jsonlPath: first.jsonlPath, target: first.target }
1189
+ : { attached: false, mirrors: [] };
1190
+ },
1191
+ resolveToolDetail,
1192
+ hasMirrorTarget: (principal) => {
1193
+ if (byTarget.has(principal))
1194
+ return true;
1195
+ // Lazy: a persisted binding counts as "attached" if its transcript is
1196
+ // still on disk. The actual re-attach happens on dispatch (async).
1197
+ const rec = deps.store.get(principal);
1198
+ if (!rec)
1199
+ return false;
1200
+ return existsSync(expandHome(rec.jsonlPath));
1201
+ },
1202
+ targetForSession: (sessionId) => bySessionId.get(sessionId)?.target,
493
1203
  shutdown: () => {
494
- if (injectStream && !injectStream.closed)
495
- void closeStream(injectStream);
496
- state?.tail.stop();
1204
+ for (const a of bySessionId.values()) {
1205
+ if (a.liveStream && !a.liveStream.closed)
1206
+ void finalizeStream(a, a.liveStream);
1207
+ a.tail.stop();
1208
+ }
1209
+ bySessionId.clear();
1210
+ byTarget.clear();
497
1211
  },
498
- dispatch: async ({ principal, text, frame, streamId }) => {
499
- if (!state) {
1212
+ dispatch: async ({ principal, text, images, frame, streamId }) => {
1213
+ // Route by inbound principal — the WeCom chat the user just messaged us
1214
+ // from is the same string we registered as `target` on attach. After a
1215
+ // daemon reload the in-memory map is empty; restore from the persisted
1216
+ // store before giving up so the conversation continues in the prior
1217
+ // claude session instead of getting "not attached".
1218
+ let a = byTarget.get(principal);
1219
+ if (!a)
1220
+ a = await restoreFromStore(principal);
1221
+ if (!a) {
500
1222
  try {
501
- await client.replyStream(frame, streamId, "[mirror] not attached — run `/wrc mirror` inside the target Claude session", true);
1223
+ await client.replyStream(frame, streamId, "[weclaude] wecom remote control not attached — run `/wrc` inside the target Claude session", true);
502
1224
  }
503
1225
  catch {
504
1226
  /* ignore */
505
1227
  }
506
1228
  return;
507
1229
  }
508
- // Eager ack so WeCom doesn't time out the inbound frame while this turn
509
- // sits in the per-session inject queue.
510
- try {
511
- await client.replyStream(frame, streamId, "…", false);
1230
+ // Finalize prior live stream (if any) so this new turn renders into its
1231
+ // own message bubble. Then open a fresh stream tied to the new frame and
1232
+ // ack immediately so WeCom doesn't time out while inject queues.
1233
+ const armMigration = isClearCommand(text);
1234
+ // Snapshot the project dir BEFORE inject runs. Claude rotates the session
1235
+ // synchronously while processing /clear and writes the rotated jsonl
1236
+ // immediately — capturing baseline post-inject would already include it,
1237
+ // and the watcher would never see a "new" candidate (the bug this fixes).
1238
+ const preClearBaseline = armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
1239
+ // /clear produces no assistant output; opening a stream would leave a
1240
+ // stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
1241
+ // success path; only surface a terse "clean" if inject fails.
1242
+ if (a.liveStream && !a.liveStream.closed) {
1243
+ await finalizeStream(a, a.liveStream);
512
1244
  }
513
- catch (e) {
514
- log.warn({ err: e.message }, "stream initial ack failed");
1245
+ const s = armMigration ? undefined : openStream(a, frame, streamId);
1246
+ if (s) {
1247
+ a.liveStream = s;
1248
+ try {
1249
+ await client.replyStream(frame, streamId, "…", false);
1250
+ }
1251
+ catch (e) {
1252
+ log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
1253
+ }
515
1254
  }
516
- const sid = state.sessionId;
1255
+ const sid = a.sessionId;
517
1256
  await enqueue(sid, async () => {
518
- const s = openStream(frame, streamId);
519
- injectStream = s;
520
- resetIdle(s);
521
- const r = await inject({ text, cfg, log: log.child({ principal }), sessionId: sid });
1257
+ if (s && s.closed)
1258
+ return; // superseded by a newer dispatch
1259
+ // Always reincarnate when no live pane: covers (a) pane closed between
1260
+ // turns, (b) daemon reload restored a binding without a live pane, AND
1261
+ // (c) /wrc'd from a non-tmux context (no tmuxSession ever stored) —
1262
+ // that last case used to permanently lock the chat into spawn-mode.
1263
+ // If respawn fails, fall through to spawn-mode inject for THIS turn
1264
+ // but DON'T erase tmuxSession from store — next inbound will retry,
1265
+ // making the system self-healing instead of one-failure-permanent.
1266
+ const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
1267
+ let freshSpawn = false;
1268
+ if (!paneAlive) {
1269
+ log.warn({ target: a.target, sessionId: sid, oldPane: a.tmuxPane, oldSession: a.tmuxSession }, "mirror: no live tmux pane, respawning");
1270
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: a.target });
1271
+ if (r.ok && r.tmuxPane && r.tmuxSession) {
1272
+ a.tmuxPane = r.tmuxPane;
1273
+ a.tmuxSession = r.tmuxSession;
1274
+ freshSpawn = true;
1275
+ deps.store.set(a.target, {
1276
+ sessionId: sid,
1277
+ jsonlPath: a.jsonlPath,
1278
+ tmuxSession: a.tmuxSession,
1279
+ tmuxPane: a.tmuxPane,
1280
+ });
1281
+ log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
1282
+ }
1283
+ else {
1284
+ // Drop only the stale pane id; keep tmuxSession (if any) so the
1285
+ // store still reflects "user wanted tmux" — next turn retries.
1286
+ log.error({ reason: r.reason }, "mirror: tmux respawn failed, this turn falls back to spawn-mode");
1287
+ a.tmuxPane = "";
1288
+ }
1289
+ }
1290
+ // Remember BEFORE inject (not after success): a fresh-spawn paste can
1291
+ // submit late, after our verifier reports failure. The tail still
1292
+ // surfaces the user's text, and without a pre-recorded entry the
1293
+ // dedupe filter wouldn't suppress it — user's chat msg gets echoed
1294
+ // back as a quoted bubble. Recording up front fixes that.
1295
+ rememberInject(text);
1296
+ const r = await inject({
1297
+ text, images, cfg, log: log.child({ principal, sessionId: sid }),
1298
+ sessionId: sid, tmuxTarget: a.tmuxPane, freshSpawn,
1299
+ });
522
1300
  if (!r.ok) {
523
- s.acc = `[mirror] ✗ ${r.reason ?? "failed"}`;
524
- await closeStream(s);
525
- if (injectStream === s)
526
- injectStream = undefined;
1301
+ if (s) {
1302
+ s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
1303
+ await finalizeStream(a, s);
1304
+ }
1305
+ else {
1306
+ // /clear path has no live stream — surface failure as a one-shot
1307
+ // terse reply ("clean" per project convention).
1308
+ try {
1309
+ await client.replyStream(frame, streamId, "clean", true);
1310
+ }
1311
+ catch { /* ignore */ }
1312
+ }
527
1313
  return;
528
1314
  }
529
- rememberInject(text);
530
- // Hold the queue until the stream finalizes (idle-driven by tail) so
531
- // back-to-back messages don't interleave their tool/text streams.
532
- await s.closedPromise;
533
- if (injectStream === s)
534
- injectStream = undefined;
1315
+ // /clear was just injected — claude rotates sessionId on the next user
1316
+ // input. Arm a watcher to migrate the attachment onto the new jsonl,
1317
+ // and surface a standalone "cleared" so the user gets explicit
1318
+ // feedback (the skip-stream path otherwise leaves WeCom silent).
1319
+ if (armMigration) {
1320
+ sendStandalone(a, "cleared");
1321
+ startMigrationWatcher(a, preClearBaseline);
1322
+ }
1323
+ // Don't await the stream's lifetime — it stays open until next inbound
1324
+ // or hard timeout. Releasing the inject queue here lets the next
1325
+ // dispatch start its inject promptly while tail content keeps flowing
1326
+ // into the (still-open) stream until superseded.
535
1327
  });
536
1328
  },
537
1329
  };
538
1330
  };
1331
+ const TOOL_DETAIL_PREFIX_EXT = "TOOL_DETAIL|";
1332
+ // Install a click-handler for the "查看详情" button. On click, look up the
1333
+ // turn's tool entries and push them as standalone markdown messages to the
1334
+ // originating chat.
1335
+ export const installMirrorEventListener = (client, bridge, log) => {
1336
+ client.on("event.template_card_event", (frame) => {
1337
+ const ev = frame.body?.event;
1338
+ const key = ev?.event_key ?? "";
1339
+ if (!key.startsWith(TOOL_DETAIL_PREFIX_EXT))
1340
+ return;
1341
+ const turnId = key.slice(TOOL_DETAIL_PREFIX_EXT.length);
1342
+ const detail = bridge.resolveToolDetail(turnId);
1343
+ if (!detail) {
1344
+ log.warn({ turnId }, "tool detail expired or unknown");
1345
+ return;
1346
+ }
1347
+ const chatId = detail.target.includes(":") ? detail.target.slice(detail.target.indexOf(":") + 1) : detail.target;
1348
+ void (async () => {
1349
+ for (const md of detail.markdown) {
1350
+ try {
1351
+ await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: md } });
1352
+ }
1353
+ catch (e) {
1354
+ log.warn({ err: e.message, turnId }, "tool detail push failed");
1355
+ }
1356
+ }
1357
+ })();
1358
+ });
1359
+ };
539
1360
  //# sourceMappingURL=mirror-bridge.js.map