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.
- package/LICENSE +1 -1
- package/README.md +105 -28
- package/cli/{wrc.sh → weclaude.sh} +34 -18
- package/commands/wrc.md +4 -4
- package/config.example.jsonc +6 -6
- package/dist/cli/init.js +10 -10
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/sync.js +35 -18
- package/dist/cli/sync.js.map +1 -1
- package/dist/daemon/approval.js +487 -37
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-bridge.js +37 -20
- package/dist/daemon/cc-bridge.js.map +1 -1
- package/dist/daemon/claim.js +20 -1
- package/dist/daemon/claim.js.map +1 -1
- package/dist/daemon/detail.js +500 -0
- package/dist/daemon/detail.js.map +1 -0
- package/dist/daemon/http.js +2 -1
- package/dist/daemon/http.js.map +1 -1
- package/dist/daemon/inbound.js +115 -21
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +30 -8
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +1010 -153
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js +39 -0
- package/dist/daemon/mirror-store.js.map +1 -0
- package/dist/daemon/pending.js +46 -0
- package/dist/daemon/pending.js.map +1 -1
- package/dist/daemon/session-cache.js +71 -3
- package/dist/daemon/session-cache.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +132 -0
- package/dist/daemon/spawn-tmux.js.map +1 -0
- package/dist/mcp/server.js +104 -65
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config-writer.js +1 -1
- package/dist/shared/config.js +34 -20
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/paths.js +6 -0
- package/dist/shared/paths.js.map +1 -1
- package/docs/DESIGN-INIT.md +6 -6
- package/docs/ONBOARDING.md +25 -25
- package/hooks/pre-tool-use.sh +42 -7
- package/launchd/{com.cc-wecom.daemon.plist.template → com.weclaude.daemon.plist.template} +3 -3
- package/package.json +10 -11
- package/scripts/install.sh +6 -6
- package/scripts/uninstall.sh +3 -3
- 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
|
|
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: "
|
|
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
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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: "
|
|
154
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
// (
|
|
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
|
|
359
|
-
const STREAM_SOFT_CAP = 18_000;
|
|
360
|
-
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
743
|
+
if (s.hardTimer) {
|
|
744
|
+
clearTimeout(s.hardTimer);
|
|
745
|
+
s.hardTimer = undefined;
|
|
393
746
|
}
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (s.
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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}…(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.
|
|
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: () =>
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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, "[
|
|
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
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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 =
|
|
1291
|
+
const sid = a.sessionId;
|
|
517
1292
|
await enqueue(sid, async () => {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
if (
|
|
534
|
-
|
|
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
|