wechat-claude-sessions 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +264 -0
- package/README.md +245 -0
- package/SECURITY.md +45 -0
- package/dist/bindings.d.ts +4 -0
- package/dist/bindings.js +50 -0
- package/dist/bindings.js.map +1 -0
- package/dist/claude-config.d.ts +4 -0
- package/dist/claude-config.js +49 -0
- package/dist/claude-config.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +345 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +771 -0
- package/dist/daemon.js.map +1 -0
- package/dist/i18n.d.ts +67 -0
- package/dist/i18n.js +249 -0
- package/dist/i18n.js.map +1 -0
- package/dist/ilink.d.ts +49 -0
- package/dist/ilink.js +533 -0
- package/dist/ilink.js.map +1 -0
- package/dist/inbox.d.ts +4 -0
- package/dist/inbox.js +75 -0
- package/dist/inbox.js.map +1 -0
- package/dist/launchd.d.ts +7 -0
- package/dist/launchd.js +71 -0
- package/dist/launchd.js.map +1 -0
- package/dist/monitoring.d.ts +4 -0
- package/dist/monitoring.js +33 -0
- package/dist/monitoring.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +35 -0
- package/dist/paths.js.map +1 -0
- package/dist/pkg-root.d.ts +2 -0
- package/dist/pkg-root.js +11 -0
- package/dist/pkg-root.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +364 -0
- package/dist/server.js.map +1 -0
- package/dist/session-numbers.d.ts +1 -0
- package/dist/session-numbers.js +44 -0
- package/dist/session-numbers.js.map +1 -0
- package/dist/sessions.d.ts +15 -0
- package/dist/sessions.js +135 -0
- package/dist/sessions.js.map +1 -0
- package/dist/tmux.d.ts +7 -0
- package/dist/tmux.js +47 -0
- package/dist/tmux.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +12 -0
- package/dist/utils.js +88 -0
- package/dist/utils.js.map +1 -0
- package/dist/watch-inbox.d.ts +2 -0
- package/dist/watch-inbox.js +102 -0
- package/dist/watch-inbox.js.map +1 -0
- package/package.json +50 -0
- package/templates/com.wechat-claude.daemon.plist.template +31 -0
- package/templates/wechat.md +20 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { ILinkClient } from "./ilink.js";
|
|
7
|
+
import { clearBinding, clearBindingsToSession, getBinding, setBinding, } from "./bindings.js";
|
|
8
|
+
import { clearHeartbeat, isMonitoring } from "./monitoring.js";
|
|
9
|
+
import { CONFIG_FILE, CURSOR_FILE, DAEMON_PID_FILE, EXPIRED_FLAG_FILE, INBOX_DIR, MEDIA_DIR, SESSIONS_DIR, TYPING_DIR, WECHAT_DIR, ensureDirs as ensureWechatDirs, isProcessAlive, } from "./paths.js";
|
|
10
|
+
import { assignSessionNumbers } from "./session-numbers.js";
|
|
11
|
+
import { findSession, getDefaultTarget, listSessions, matchSessions, sessionLabel, sortedSessions, } from "./sessions.js";
|
|
12
|
+
import { writeToInbox } from "./inbox.js";
|
|
13
|
+
import { CLAUDE_CONFIG_FILE, ensureBypassAccepted } from "./claude-config.js";
|
|
14
|
+
import { formatAgo, getLang, marker, t } from "./i18n.js";
|
|
15
|
+
import { hasTmux, isSafeSessionName, killTmuxSession, listTmuxSessions, newTmuxSession, sanitizeForSessionName, tmuxSessionExists, } from "./tmux.js";
|
|
16
|
+
import { buildRunPrompt, extractText as sharedExtractText, parseRunFlags, } from "./utils.js";
|
|
17
|
+
function ensureDirs() {
|
|
18
|
+
ensureWechatDirs([MEDIA_DIR]);
|
|
19
|
+
}
|
|
20
|
+
const MEDIA_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
21
|
+
const UNREAD_WARN_AFTER_MS = 120_000;
|
|
22
|
+
const DELIVERY_EXPIRE_MS = 600_000;
|
|
23
|
+
const IDLE_MS = 2 * 60 * 60 * 1000;
|
|
24
|
+
function cleanOldMedia() {
|
|
25
|
+
try {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
for (const file of fs.readdirSync(MEDIA_DIR)) {
|
|
28
|
+
const full = path.join(MEDIA_DIR, file);
|
|
29
|
+
try {
|
|
30
|
+
if (now - fs.statSync(full).mtimeMs > MEDIA_MAX_AGE_MS) {
|
|
31
|
+
fs.unlinkSync(full);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
}
|
|
39
|
+
// Download incoming images so any Claude session can Read them from disk;
|
|
40
|
+
// replaces each "[图片]" placeholder in the text with the local file path.
|
|
41
|
+
async function enrichImages(client, pending) {
|
|
42
|
+
const items = pending.rawItems ?? [];
|
|
43
|
+
let index = 0;
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
if (item.type !== 2)
|
|
46
|
+
continue;
|
|
47
|
+
index += 1;
|
|
48
|
+
try {
|
|
49
|
+
const outBase = path.join(MEDIA_DIR, `${pending.id}-${index}`);
|
|
50
|
+
const saved = await client.downloadMedia(item.image_item.media, outBase);
|
|
51
|
+
const lang = getLang();
|
|
52
|
+
pending.text = pending.text.replace(marker("image", lang), `${marker("image", lang).replace(/\]$/, "")}: ${saved}]`);
|
|
53
|
+
log(`Image saved: ${saved}`);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
log(`Image download failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function getParentPid(pid) {
|
|
61
|
+
const r = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
62
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
63
|
+
});
|
|
64
|
+
if (r.status !== 0 || !r.stdout)
|
|
65
|
+
return undefined;
|
|
66
|
+
const ppid = parseInt(r.stdout.toString().trim(), 10);
|
|
67
|
+
return Number.isFinite(ppid) && ppid > 1 ? ppid : undefined;
|
|
68
|
+
}
|
|
69
|
+
function commandOf(pid) {
|
|
70
|
+
const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], {
|
|
71
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
72
|
+
});
|
|
73
|
+
if (r.status !== 0 || !r.stdout)
|
|
74
|
+
return "";
|
|
75
|
+
return r.stdout.toString().trim();
|
|
76
|
+
}
|
|
77
|
+
// Terminate a session: its MCP server process and — when it is identifiably a
|
|
78
|
+
// claude process — the parent Claude Code process, then clean up the registry.
|
|
79
|
+
function closeSession(s) {
|
|
80
|
+
const parent = getParentPid(s.pid);
|
|
81
|
+
const parentIsClaude = parent !== undefined && /claude/i.test(commandOf(parent));
|
|
82
|
+
let killedParent = false;
|
|
83
|
+
if (parent !== undefined && parentIsClaude) {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(parent, "SIGTERM");
|
|
86
|
+
killedParent = true;
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
}
|
|
90
|
+
let killedServer = false;
|
|
91
|
+
try {
|
|
92
|
+
process.kill(s.pid, "SIGTERM");
|
|
93
|
+
killedServer = true;
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
try {
|
|
97
|
+
fs.unlinkSync(path.join(SESSIONS_DIR, `${s.id}.json`));
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
try {
|
|
101
|
+
fs.unlinkSync(path.join(INBOX_DIR, `${s.id}.json`));
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
clearHeartbeat(s.id);
|
|
105
|
+
clearBindingsToSession(s.id);
|
|
106
|
+
if (!killedServer && !killedParent) {
|
|
107
|
+
return { ok: false, line: t().closeFailedLine(s.name, s.pid) };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, line: t().closeOkLine(s.name, s.pid) };
|
|
110
|
+
}
|
|
111
|
+
function remainingSummary(closedIds) {
|
|
112
|
+
const remaining = listSessions().filter((s) => !closedIds.has(s.id));
|
|
113
|
+
const monitored = remaining.filter((s) => isMonitoring(s.id)).length;
|
|
114
|
+
return t().remainingSummary(remaining.length, monitored);
|
|
115
|
+
}
|
|
116
|
+
function getCursor() {
|
|
117
|
+
try {
|
|
118
|
+
return fs.readFileSync(CURSOR_FILE, "utf-8").trim();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function setCursor(cursor) {
|
|
125
|
+
fs.writeFileSync(CURSOR_FILE, cursor);
|
|
126
|
+
}
|
|
127
|
+
function markTyping(userId) {
|
|
128
|
+
fs.writeFileSync(path.join(TYPING_DIR, userId), String(Date.now()));
|
|
129
|
+
}
|
|
130
|
+
function isTypingActive(userId) {
|
|
131
|
+
try {
|
|
132
|
+
const file = path.join(TYPING_DIR, userId);
|
|
133
|
+
if (!fs.existsSync(file))
|
|
134
|
+
return false;
|
|
135
|
+
const ts = parseInt(fs.readFileSync(file, "utf-8").trim(), 10);
|
|
136
|
+
return Date.now() - ts < 60_000;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const extractText = sharedExtractText;
|
|
143
|
+
const deliveries = [];
|
|
144
|
+
function trackDelivery(msgId, sessionId, targetName, fromUserId) {
|
|
145
|
+
deliveries.push({
|
|
146
|
+
msgId,
|
|
147
|
+
sessionId,
|
|
148
|
+
targetName,
|
|
149
|
+
fromUserId,
|
|
150
|
+
deliveredAt: Date.now(),
|
|
151
|
+
warned: false,
|
|
152
|
+
});
|
|
153
|
+
if (deliveries.length > 200)
|
|
154
|
+
deliveries.shift();
|
|
155
|
+
}
|
|
156
|
+
function startDeliveryWatcher(client) {
|
|
157
|
+
setInterval(() => {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (let i = deliveries.length - 1; i >= 0; i--) {
|
|
160
|
+
const d = deliveries[i];
|
|
161
|
+
let stillPending = false;
|
|
162
|
+
try {
|
|
163
|
+
const inbox = JSON.parse(fs.readFileSync(path.join(INBOX_DIR, `${d.sessionId}.json`), "utf-8"));
|
|
164
|
+
stillPending = inbox.some((m) => m.id === d.msgId);
|
|
165
|
+
}
|
|
166
|
+
catch { }
|
|
167
|
+
if (!stillPending || now - d.deliveredAt > DELIVERY_EXPIRE_MS) {
|
|
168
|
+
deliveries.splice(i, 1);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!d.warned && now - d.deliveredAt > UNREAD_WARN_AFTER_MS) {
|
|
172
|
+
d.warned = true;
|
|
173
|
+
client
|
|
174
|
+
.sendText(d.fromUserId, t().unreadWarn(d.targetName))
|
|
175
|
+
.catch((err) => log(`Unread warning failed: ${err}`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}, 20_000);
|
|
179
|
+
}
|
|
180
|
+
function notifyMacOS(message) {
|
|
181
|
+
if (process.platform !== "darwin")
|
|
182
|
+
return;
|
|
183
|
+
// Pass text as an argv element, never interpolated into the -e script.
|
|
184
|
+
const script = "display notification (system attribute \"WC_MSG\") with title \"wechat-claude\"";
|
|
185
|
+
spawnSync("osascript", ["-e", script], {
|
|
186
|
+
stdio: "ignore",
|
|
187
|
+
env: { ...process.env, WC_MSG: message },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function resolveRunDir(dirHint) {
|
|
191
|
+
if (!dirHint) {
|
|
192
|
+
const sessions = listSessions();
|
|
193
|
+
if (sessions.length === 0)
|
|
194
|
+
return undefined;
|
|
195
|
+
const mostActive = sessions.reduce((a, b) => (a.lastActive > b.lastActive ? a : b));
|
|
196
|
+
return mostActive.cwd;
|
|
197
|
+
}
|
|
198
|
+
const sessions = listSessions();
|
|
199
|
+
const match = sessions.find((s) => s.name.toLowerCase() === dirHint.toLowerCase() ||
|
|
200
|
+
s.name.toLowerCase().includes(dirHint.toLowerCase()) ||
|
|
201
|
+
path.basename(s.cwd).toLowerCase() === dirHint.toLowerCase());
|
|
202
|
+
if (match)
|
|
203
|
+
return match.cwd;
|
|
204
|
+
if (path.isAbsolute(dirHint)) {
|
|
205
|
+
try {
|
|
206
|
+
if (fs.statSync(dirHint).isDirectory())
|
|
207
|
+
return dirHint;
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
for (const dir of getRepoSearchDirs().dirs) {
|
|
213
|
+
const candidate = path.join(dir, dirHint);
|
|
214
|
+
try {
|
|
215
|
+
if (fs.statSync(candidate).isDirectory())
|
|
216
|
+
return candidate;
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
function expandTilde(p) {
|
|
223
|
+
if (p === "~")
|
|
224
|
+
return os.homedir();
|
|
225
|
+
if (p.startsWith("~/"))
|
|
226
|
+
return path.join(os.homedir(), p.slice(2));
|
|
227
|
+
return p;
|
|
228
|
+
}
|
|
229
|
+
// Directories to search when "/run <name> ..." names a project that has no
|
|
230
|
+
// active session: user-configured repoDirs from ~/.claude/wechat/config.json,
|
|
231
|
+
// plus the parent directories of every active session's cwd (so once you've
|
|
232
|
+
// opened a project near your other repos, its siblings resolve by name too).
|
|
233
|
+
// The home directory itself is never used as a search root — too broad.
|
|
234
|
+
function getRepoSearchDirs() {
|
|
235
|
+
const dirs = new Set();
|
|
236
|
+
let configError;
|
|
237
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
240
|
+
const repoDirs = typeof parsed === "object" && parsed !== null
|
|
241
|
+
? parsed.repoDirs
|
|
242
|
+
: undefined;
|
|
243
|
+
if (repoDirs !== undefined && !Array.isArray(repoDirs)) {
|
|
244
|
+
configError = t().cfgNotArray;
|
|
245
|
+
}
|
|
246
|
+
else if (Array.isArray(repoDirs)) {
|
|
247
|
+
for (const d of repoDirs) {
|
|
248
|
+
if (typeof d !== "string") {
|
|
249
|
+
configError = t().cfgNonString;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const expanded = expandTilde(d);
|
|
253
|
+
if (!path.isAbsolute(expanded)) {
|
|
254
|
+
configError = t().cfgNotAbsolute(d);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
dirs.add(expanded);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
configError = t().cfgBadJson;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const home = os.homedir();
|
|
266
|
+
for (const s of listSessions()) {
|
|
267
|
+
const parent = path.dirname(s.cwd);
|
|
268
|
+
if (parent !== home && parent !== path.sep)
|
|
269
|
+
dirs.add(parent);
|
|
270
|
+
}
|
|
271
|
+
if (configError)
|
|
272
|
+
log(`Config warning: ${configError}`);
|
|
273
|
+
return { dirs: [...dirs], configError };
|
|
274
|
+
}
|
|
275
|
+
// Persisted so /runs and end-of-run notifications survive daemon restarts
|
|
276
|
+
// (launchd KeepAlive makes restarts routine).
|
|
277
|
+
const RUNS_FILE = path.join(WECHAT_DIR, "runs.json");
|
|
278
|
+
function loadRunSessions() {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(fs.readFileSync(RUNS_FILE, "utf-8"));
|
|
281
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function saveRunSessions() {
|
|
288
|
+
try {
|
|
289
|
+
fs.writeFileSync(RUNS_FILE, JSON.stringify(runSessions));
|
|
290
|
+
}
|
|
291
|
+
catch { }
|
|
292
|
+
}
|
|
293
|
+
const runSessions = loadRunSessions();
|
|
294
|
+
function listRunTmuxSessions() {
|
|
295
|
+
return listTmuxSessions().filter((name) => name.startsWith("wc-"));
|
|
296
|
+
}
|
|
297
|
+
// Notify the WeChat user when a /run tmux session ends, so a crashed or
|
|
298
|
+
// finished task never disappears silently.
|
|
299
|
+
function startRunWatcher(client) {
|
|
300
|
+
setInterval(() => {
|
|
301
|
+
for (let i = runSessions.length - 1; i >= 0; i--) {
|
|
302
|
+
const r = runSessions[i];
|
|
303
|
+
if (tmuxSessionExists(r.name))
|
|
304
|
+
continue;
|
|
305
|
+
runSessions.splice(i, 1);
|
|
306
|
+
saveRunSessions();
|
|
307
|
+
const mins = Math.round((Date.now() - r.startedAt) / 60_000);
|
|
308
|
+
client
|
|
309
|
+
.sendText(r.fromUserId, t().runEnded(r.name, mins, r.task.slice(0, 80)))
|
|
310
|
+
.catch((err) => log(`Run-end notify failed: ${err}`));
|
|
311
|
+
}
|
|
312
|
+
}, 30_000);
|
|
313
|
+
}
|
|
314
|
+
function handleRunCommand(client, msg, args) {
|
|
315
|
+
const sendReply = (reply) => {
|
|
316
|
+
client.sendText(msg.fromUserId, reply).catch((err) => {
|
|
317
|
+
log(`Reply failed: ${err}`);
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
const m = t();
|
|
321
|
+
const { auto, rest } = parseRunFlags(args);
|
|
322
|
+
if (!rest) {
|
|
323
|
+
sendReply(m.runUsage);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const parts = rest.split(/\s+/);
|
|
327
|
+
let dirHint;
|
|
328
|
+
let task;
|
|
329
|
+
let cwd;
|
|
330
|
+
// "/run . <任务>" runs in the default (most recently active session's) dir
|
|
331
|
+
// even when the first task word happens to look like a directory name.
|
|
332
|
+
if (parts[0] === "." && parts.length > 1) {
|
|
333
|
+
task = parts.slice(1).join(" ");
|
|
334
|
+
cwd = resolveRunDir(undefined);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const firstWordAsDir = parts.length > 1 ? resolveRunDir(parts[0]) : undefined;
|
|
338
|
+
if (firstWordAsDir) {
|
|
339
|
+
dirHint = parts[0];
|
|
340
|
+
task = parts.slice(1).join(" ");
|
|
341
|
+
cwd = firstWordAsDir;
|
|
342
|
+
}
|
|
343
|
+
else if (parts.length > 1 && /^[A-Za-z0-9_./\\-]+$/.test(parts[0])) {
|
|
344
|
+
// Looks like a directory/repo name (e.g. "myrepo", "my-app", a path)
|
|
345
|
+
// but resolves nowhere. Running the whole text as a task in some other
|
|
346
|
+
// directory — unattended — is the worst outcome, so ask instead.
|
|
347
|
+
dirHint = parts[0];
|
|
348
|
+
task = rest;
|
|
349
|
+
cwd = undefined;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
task = rest;
|
|
353
|
+
cwd = resolveRunDir(undefined);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!cwd) {
|
|
357
|
+
if (dirHint) {
|
|
358
|
+
const search = getRepoSearchDirs();
|
|
359
|
+
const configNote = search.configError ? `\n\n⚠️ ${search.configError}` : "";
|
|
360
|
+
const sessDirs = listSessions()
|
|
361
|
+
.map((s) => ` - ${s.name} (${s.cwd})`)
|
|
362
|
+
.join("\n");
|
|
363
|
+
const searchDirs = search.dirs.map((d) => ` - ${d}`).join("\n") || m.noRepoDirs;
|
|
364
|
+
sendReply(m.runDirNotFound(dirHint, sessDirs, searchDirs, task, configNote));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
sendReply(m.runNoSession);
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!hasTmux()) {
|
|
372
|
+
sendReply(m.tmuxMissing);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const sessionName = `wc-${sanitizeForSessionName(path.basename(cwd))}-${Date.now().toString(36)}`;
|
|
376
|
+
const taskFile = path.join(os.tmpdir(), `wechat-run-${sessionName}.txt`);
|
|
377
|
+
fs.writeFileSync(taskFile, buildRunPrompt(task, msg.fromUserId));
|
|
378
|
+
// acceptEdits keeps unattended tasks moving without opening up bash; -y
|
|
379
|
+
// opts into full skip-permissions for tasks that need to run commands.
|
|
380
|
+
const permFlag = auto
|
|
381
|
+
? "--dangerously-skip-permissions"
|
|
382
|
+
: "--permission-mode acceptEdits";
|
|
383
|
+
// Skip-permissions mode has a one-time consent dialog. The session starts
|
|
384
|
+
// detached with nobody to answer it, so accept it up front or the task hangs
|
|
385
|
+
// on the prompt forever while /runs reports it as running. If we cannot
|
|
386
|
+
// confirm the acceptance, refuse to launch rather than create that hang.
|
|
387
|
+
const bypass = auto ? ensureBypassAccepted() : "already";
|
|
388
|
+
if (bypass === "accepted")
|
|
389
|
+
log("Accepted bypassPermissionsModeAccepted in ~/.claude.json");
|
|
390
|
+
if (bypass === "failed") {
|
|
391
|
+
log(`Could not accept skip-permissions mode in ${CLAUDE_CONFIG_FILE}`);
|
|
392
|
+
sendReply(m.bypassUnavailable(CLAUDE_CONFIG_FILE));
|
|
393
|
+
try {
|
|
394
|
+
fs.unlinkSync(taskFile);
|
|
395
|
+
}
|
|
396
|
+
catch { }
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// The command string is passed to tmux as a single argv element (no outer
|
|
400
|
+
// shell), so "$task" is expanded by the shell tmux starts — not by us. The
|
|
401
|
+
// task file path uses only our sanitized session name, so single-quoting it
|
|
402
|
+
// is safe.
|
|
403
|
+
const shellCmd = `task=$(cat '${taskFile}'); rm -f '${taskFile}'; exec claude ${permFlag} "$task"`;
|
|
404
|
+
const startErr = newTmuxSession(sessionName, cwd, shellCmd);
|
|
405
|
+
if (startErr) {
|
|
406
|
+
try {
|
|
407
|
+
fs.unlinkSync(taskFile);
|
|
408
|
+
}
|
|
409
|
+
catch { }
|
|
410
|
+
log(`Failed to start tmux session: ${startErr}`);
|
|
411
|
+
sendReply(m.runStartFailed(startErr));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
runSessions.push({
|
|
415
|
+
name: sessionName,
|
|
416
|
+
cwd,
|
|
417
|
+
task,
|
|
418
|
+
fromUserId: msg.fromUserId,
|
|
419
|
+
startedAt: Date.now(),
|
|
420
|
+
});
|
|
421
|
+
saveRunSessions();
|
|
422
|
+
log(`Started tmux session "${sessionName}" in ${cwd}: ${task}`);
|
|
423
|
+
sendReply(m.runStarted(path.basename(cwd), task, auto ? m.permSkip : m.permSafe, sessionName) + (bypass === "accepted" ? `\n\n${m.bypassAutoAccepted}` : ""));
|
|
424
|
+
}
|
|
425
|
+
function routeMessage(client, msg) {
|
|
426
|
+
const text = msg.text.trim();
|
|
427
|
+
const lang = getLang();
|
|
428
|
+
const m = t(lang);
|
|
429
|
+
const sendReply = (reply) => {
|
|
430
|
+
client.sendText(msg.fromUserId, reply).catch((err) => {
|
|
431
|
+
log(`Reply failed: ${err}`);
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
const runMatch = text.match(/^\/run(?:\s+([\s\S]*))?$/);
|
|
435
|
+
if (runMatch) {
|
|
436
|
+
handleRunCommand(client, msg, runMatch[1] ?? "");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (text === "/runs") {
|
|
440
|
+
const names = listRunTmuxSessions();
|
|
441
|
+
if (names.length === 0) {
|
|
442
|
+
sendReply(m.runsNone);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const lines = names.map((name, i) => {
|
|
446
|
+
const tracked = runSessions.find((r) => r.name === name);
|
|
447
|
+
if (!tracked)
|
|
448
|
+
return m.runsEntryBare(i + 1, name);
|
|
449
|
+
const mins = Math.round((Date.now() - tracked.startedAt) / 60_000);
|
|
450
|
+
return m.runsEntry(i + 1, name, mins, tracked.task.slice(0, 60));
|
|
451
|
+
});
|
|
452
|
+
sendReply(m.runsList(names.length, lines.join("\n\n")));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const useMatch = text.match(/^\/use(?:\s+(\S+))?\s*$/);
|
|
456
|
+
if (useMatch) {
|
|
457
|
+
const selector = useMatch[1];
|
|
458
|
+
if (!selector) {
|
|
459
|
+
const boundId = getBinding(msg.fromUserId);
|
|
460
|
+
const bound = boundId
|
|
461
|
+
? listSessions().find((s) => s.id === boundId)
|
|
462
|
+
: undefined;
|
|
463
|
+
sendReply(bound ? m.useCurrent(bound.name, bound.pid) : m.useNone);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (selector === "off" || selector === "取消") {
|
|
467
|
+
clearBinding(msg.fromUserId);
|
|
468
|
+
sendReply(m.useUnbound);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const target = findSession(selector);
|
|
472
|
+
if (!target) {
|
|
473
|
+
sendReply(m.notFound(selector));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
setBinding(msg.fromUserId, target.id);
|
|
477
|
+
const warn = isMonitoring(target.id) ? "" : m.useNotMonitoredWarn;
|
|
478
|
+
sendReply(m.useBound(target.name, target.pid, warn));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const closeMatch = text.match(/^\/close(?:\s+(\S+))?(?:\s+(all|全部))?\s*$/);
|
|
482
|
+
if (closeMatch) {
|
|
483
|
+
const selector = closeMatch[1];
|
|
484
|
+
const closeAll = closeMatch[2] !== undefined;
|
|
485
|
+
if (!selector) {
|
|
486
|
+
sendReply(m.closeUsage);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let targets;
|
|
490
|
+
if (selector === "idle" || selector === "闲置") {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
targets = sortedSessions().filter((s) => !isMonitoring(s.id) && now - s.lastActive >= IDLE_MS);
|
|
493
|
+
if (targets.length === 0) {
|
|
494
|
+
sendReply(m.closeNoIdle);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
targets = matchSessions(selector);
|
|
500
|
+
if (targets.length === 0) {
|
|
501
|
+
sendReply(m.notFound(selector));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (targets.length > 1 && !closeAll) {
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
const lines = targets.map((s) => {
|
|
507
|
+
const mon = isMonitoring(s.id) ? m.monitoringSuffix : "";
|
|
508
|
+
return m.closeAmbiguousEntry(s.name, s.pid, formatAgo(now - s.lastActive, lang), mon);
|
|
509
|
+
});
|
|
510
|
+
sendReply(m.closeAmbiguous(selector, targets.length, lines.join("\n")));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const results = targets.map((s) => closeSession(s));
|
|
515
|
+
const closedIds = new Set(targets.map((s) => s.id));
|
|
516
|
+
for (const s of targets)
|
|
517
|
+
log(`Closed session ${s.name} (${s.id}) via WeChat`);
|
|
518
|
+
sendReply(m.closed(targets.length, results.map((r) => r.line).join("\n"), remainingSummary(closedIds)));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const stopMatch = text.match(/^\/stop\s+(\S+)\s*$/);
|
|
522
|
+
if (stopMatch) {
|
|
523
|
+
const selector = stopMatch[1];
|
|
524
|
+
const running = listRunTmuxSessions();
|
|
525
|
+
if (running.length === 0) {
|
|
526
|
+
sendReply(m.runsNone);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
let targets;
|
|
530
|
+
if (selector === "all" || selector === "全部") {
|
|
531
|
+
targets = running;
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
const num = parseInt(selector, 10);
|
|
535
|
+
if (!isNaN(num) && String(num) === selector) {
|
|
536
|
+
const byNum = running[num - 1];
|
|
537
|
+
if (!byNum) {
|
|
538
|
+
sendReply(m.stopNumOutOfRange(num));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
targets = [byNum];
|
|
542
|
+
}
|
|
543
|
+
else if (running.includes(selector)) {
|
|
544
|
+
targets = [selector];
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
sendReply(m.stopNotFound(selector));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Guard: only ever kill our own wc- sessions, whatever the selector.
|
|
552
|
+
targets = targets.filter((n) => n.startsWith("wc-") && isSafeSessionName(n));
|
|
553
|
+
const stopped = [];
|
|
554
|
+
for (const name of targets) {
|
|
555
|
+
if (killTmuxSession(name)) {
|
|
556
|
+
stopped.push(name);
|
|
557
|
+
const idx = runSessions.findIndex((r) => r.name === name);
|
|
558
|
+
if (idx >= 0)
|
|
559
|
+
runSessions.splice(idx, 1);
|
|
560
|
+
log(`Stopped tmux session "${name}" via WeChat`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
saveRunSessions();
|
|
564
|
+
sendReply(stopped.length > 0
|
|
565
|
+
? m.stopped(stopped.length, stopped.map((n) => `• ${n}`).join("\n"))
|
|
566
|
+
: m.stopFailed);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (text === "/help" || text === "/h") {
|
|
570
|
+
sendReply(m.help);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const listMatch = text.match(/^\/(?:sessions|ls)(?:\s+(all|全部))?$/);
|
|
574
|
+
if (listMatch) {
|
|
575
|
+
const showAll = listMatch[1] !== undefined;
|
|
576
|
+
const sessions = sortedSessions();
|
|
577
|
+
if (sessions.length === 0) {
|
|
578
|
+
sendReply(m.noSessions);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const defaultTarget = getDefaultTarget(sessions);
|
|
582
|
+
const boundId = getBinding(msg.fromUserId);
|
|
583
|
+
const bound = sessions.find((s) => s.id === boundId);
|
|
584
|
+
const receiverId = bound?.id ?? defaultTarget?.id;
|
|
585
|
+
const numbers = assignSessionNumbers(sessions.map((s) => s.id));
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
const isIdle = (s) => !showAll &&
|
|
588
|
+
!isMonitoring(s.id) &&
|
|
589
|
+
s.id !== receiverId &&
|
|
590
|
+
now - s.lastActive >= IDLE_MS;
|
|
591
|
+
const entries = sessions.map((s) => ({ s, num: numbers[s.id] }));
|
|
592
|
+
const mainLines = entries
|
|
593
|
+
.filter(({ s }) => !isIdle(s))
|
|
594
|
+
.map(({ s, num }) => {
|
|
595
|
+
const active = now - s.lastActive < 120_000 ? "●" : "○";
|
|
596
|
+
const tags = (isMonitoring(s.id) ? m.monitoringTag : "") +
|
|
597
|
+
(s.id === receiverId ? (bound ? m.boundTag : m.defaultTag) : "");
|
|
598
|
+
return m.sessionEntry(active, num, sessionLabel(s, sessions), tags, path.basename(s.cwd), formatAgo(now - s.lastActive, lang));
|
|
599
|
+
});
|
|
600
|
+
const idleLines = entries
|
|
601
|
+
.filter(({ s }) => isIdle(s))
|
|
602
|
+
.map(({ s, num }) => m.idleEntry(num, sessionLabel(s, sessions), formatAgo(now - s.lastActive, lang)));
|
|
603
|
+
const sections = [
|
|
604
|
+
m.sessionsHeader(sessions.length),
|
|
605
|
+
mainLines.join("\n\n"),
|
|
606
|
+
idleLines.length > 0 ? m.idleSection(idleLines.join("\n")) : "",
|
|
607
|
+
[
|
|
608
|
+
bound ? m.legendBound : m.legendDefault,
|
|
609
|
+
m.legendNumbers,
|
|
610
|
+
m.legendRoute,
|
|
611
|
+
].join("\n"),
|
|
612
|
+
].filter(Boolean);
|
|
613
|
+
sendReply(sections.join("\n\n"));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const bareRoute = text.match(/^\/s(?:\s+(\S+))?\s*$/);
|
|
617
|
+
if (bareRoute) {
|
|
618
|
+
sendReply(m.sListUsage);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const routeMatch = text.match(/^\/s\s+(\S+)\s+([\s\S]+)$/);
|
|
622
|
+
if (routeMatch) {
|
|
623
|
+
const selector = routeMatch[1];
|
|
624
|
+
const message = routeMatch[2];
|
|
625
|
+
const target = findSession(selector);
|
|
626
|
+
if (!target) {
|
|
627
|
+
sendReply(m.notFound(selector));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
writeToInbox(target.id, { ...msg, text: message });
|
|
631
|
+
trackDelivery(msg.id, target.id, target.name, msg.fromUserId);
|
|
632
|
+
if (!isMonitoring(target.id)) {
|
|
633
|
+
sendReply(m.deliveredUnmonitored(target.name));
|
|
634
|
+
}
|
|
635
|
+
markTyping(msg.fromUserId);
|
|
636
|
+
client.startTypingKeepAlive(msg.fromUserId, () => isTypingActive(msg.fromUserId));
|
|
637
|
+
log(`Routed to ${target.name} (${target.id})`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const sessions = listSessions();
|
|
641
|
+
// A bound session wins; otherwise prefer sessions that are actively
|
|
642
|
+
// monitoring their inbox and, among those, the most recently active one.
|
|
643
|
+
const boundId = getBinding(msg.fromUserId);
|
|
644
|
+
let target = boundId
|
|
645
|
+
? sessions.find((s) => s.id === boundId)
|
|
646
|
+
: undefined;
|
|
647
|
+
if (boundId && !target) {
|
|
648
|
+
clearBinding(msg.fromUserId);
|
|
649
|
+
sendReply(m.bindingCleared);
|
|
650
|
+
}
|
|
651
|
+
target = target ?? getDefaultTarget(sessions);
|
|
652
|
+
if (!target) {
|
|
653
|
+
sendReply(m.noSessionsDeliver);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
writeToInbox(target.id, msg);
|
|
657
|
+
trackDelivery(msg.id, target.id, target.name, msg.fromUserId);
|
|
658
|
+
if (!isMonitoring(target.id)) {
|
|
659
|
+
sendReply(m.deliveredNoneMonitored(target.name));
|
|
660
|
+
}
|
|
661
|
+
markTyping(msg.fromUserId);
|
|
662
|
+
client.startTypingKeepAlive(msg.fromUserId);
|
|
663
|
+
log(`Routed to ${target.name} (${target.id})`);
|
|
664
|
+
}
|
|
665
|
+
function log(msg) {
|
|
666
|
+
const time = new Date().toLocaleString("zh-CN");
|
|
667
|
+
process.stdout.write(`[${time}] ${msg}\n`);
|
|
668
|
+
}
|
|
669
|
+
function startTypingWatcher(client) {
|
|
670
|
+
setInterval(() => {
|
|
671
|
+
try {
|
|
672
|
+
for (const file of fs.readdirSync(TYPING_DIR)) {
|
|
673
|
+
if (!isTypingActive(file)) {
|
|
674
|
+
client.stopTypingKeepAlive(file);
|
|
675
|
+
try {
|
|
676
|
+
fs.unlinkSync(path.join(TYPING_DIR, file));
|
|
677
|
+
}
|
|
678
|
+
catch { }
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch { }
|
|
683
|
+
}, 5000);
|
|
684
|
+
}
|
|
685
|
+
async function main() {
|
|
686
|
+
ensureDirs();
|
|
687
|
+
try {
|
|
688
|
+
const existingPid = parseInt(fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim(), 10);
|
|
689
|
+
if (isProcessAlive(existingPid) && existingPid !== process.pid) {
|
|
690
|
+
log(`Daemon already running (pid ${existingPid}). Exiting.`);
|
|
691
|
+
process.exit(0);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch { }
|
|
695
|
+
fs.writeFileSync(DAEMON_PID_FILE, String(process.pid));
|
|
696
|
+
const cleanup = () => {
|
|
697
|
+
try {
|
|
698
|
+
fs.unlinkSync(DAEMON_PID_FILE);
|
|
699
|
+
}
|
|
700
|
+
catch { }
|
|
701
|
+
};
|
|
702
|
+
process.on("exit", cleanup);
|
|
703
|
+
process.on("SIGINT", () => process.exit(0));
|
|
704
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
705
|
+
const client = new ILinkClient();
|
|
706
|
+
if (!client.tryRestoreSession()) {
|
|
707
|
+
log("No session found. Login via Claude Code first, then restart daemon.");
|
|
708
|
+
process.exit(1);
|
|
709
|
+
}
|
|
710
|
+
const cursor = getCursor();
|
|
711
|
+
if (cursor)
|
|
712
|
+
client.setUpdatesCursor(cursor);
|
|
713
|
+
cleanOldMedia();
|
|
714
|
+
startTypingWatcher(client);
|
|
715
|
+
startDeliveryWatcher(client);
|
|
716
|
+
startRunWatcher(client);
|
|
717
|
+
log(`Daemon started (pid ${process.pid}). Polling WeChat...`);
|
|
718
|
+
let clearedExpiredFlag = false;
|
|
719
|
+
while (true) {
|
|
720
|
+
try {
|
|
721
|
+
const rawMsgs = await client.getUpdates();
|
|
722
|
+
setCursor(client.getUpdatesCursor());
|
|
723
|
+
if (!clearedExpiredFlag) {
|
|
724
|
+
// Polling works, so the login is valid — clear any stale expiry flag.
|
|
725
|
+
try {
|
|
726
|
+
fs.unlinkSync(EXPIRED_FLAG_FILE);
|
|
727
|
+
}
|
|
728
|
+
catch { }
|
|
729
|
+
clearedExpiredFlag = true;
|
|
730
|
+
}
|
|
731
|
+
for (const msg of rawMsgs) {
|
|
732
|
+
if (msg.message_type !== 1 || msg.message_state !== 2)
|
|
733
|
+
continue;
|
|
734
|
+
client.trackContextToken(msg.from_user_id, msg.context_token);
|
|
735
|
+
const text = extractText(msg, getLang());
|
|
736
|
+
if (!text)
|
|
737
|
+
continue;
|
|
738
|
+
const pending = {
|
|
739
|
+
id: msg.message_id,
|
|
740
|
+
fromUserId: msg.from_user_id,
|
|
741
|
+
text,
|
|
742
|
+
contextToken: msg.context_token,
|
|
743
|
+
timestamp: msg.create_time_ms,
|
|
744
|
+
rawItems: msg.item_list,
|
|
745
|
+
};
|
|
746
|
+
await enrichImages(client, pending);
|
|
747
|
+
log(`Message: ${pending.text.slice(0, 60)}`);
|
|
748
|
+
routeMessage(client, pending);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
753
|
+
if (errMsg.includes("Session expired")) {
|
|
754
|
+
log("Session expired. Exiting.");
|
|
755
|
+
try {
|
|
756
|
+
fs.writeFileSync(EXPIRED_FLAG_FILE, String(Date.now()));
|
|
757
|
+
}
|
|
758
|
+
catch { }
|
|
759
|
+
notifyMacOS(t().loginExpired);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
log(`Poll error: ${errMsg}`);
|
|
763
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
main().catch((err) => {
|
|
768
|
+
log(`Fatal: ${err}`);
|
|
769
|
+
process.exit(1);
|
|
770
|
+
});
|
|
771
|
+
//# sourceMappingURL=daemon.js.map
|