weclaude 0.0.4 → 0.1.1

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