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