claude-code-telegram-gateway 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/LICENSE +21 -0
- package/README.md +156 -0
- package/SETUP.md +75 -0
- package/bin/claude-tg.js +30 -0
- package/com.claude.telegram-gateway.plist +49 -0
- package/config.example.json +29 -0
- package/gateway.js +1311 -0
- package/install-service.sh +64 -0
- package/package.json +56 -0
- package/resume-hook.js +17 -0
- package/setup.js +108 -0
- package/systemd/claude-gateway.service +23 -0
- package/test/MANUAL-TESTS.md +85 -0
- package/test/check-telegram.js +55 -0
- package/test/gateway.test.js +523 -0
- package/test/inject-probe.sh +49 -0
- package/test/reset-topics.js +42 -0
- package/uninstall-service.sh +17 -0
package/gateway.js
ADDED
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { spawn, execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Config
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
|
11
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
12
|
+
console.error("Error: config.json not found. Run `npm run setup`, or copy config.example.json to config.json and fill it in.");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
16
|
+
const {
|
|
17
|
+
BOT_TOKEN,
|
|
18
|
+
ALLOWED_USER_IDS,
|
|
19
|
+
REPO_MAPPINGS,
|
|
20
|
+
CLAUDE_PATH,
|
|
21
|
+
PERMISSION_MODE, // bypassPermissions (default) | acceptEdits | manual | plan | dontAsk | auto
|
|
22
|
+
MODEL, // e.g. "opus"
|
|
23
|
+
EXTRA_ARGS, // array of extra CLI args, e.g. ["--effort","high"]
|
|
24
|
+
SHOW_TOOL_ACTIVITY // bool, default true โ show ๐ง tool steps
|
|
25
|
+
} = config;
|
|
26
|
+
|
|
27
|
+
const BOT_ID = (BOT_TOKEN || '').split(':')[0]; // bot's own user id (token prefix)
|
|
28
|
+
const CLAUDE_BINARY = CLAUDE_PATH || 'claude';
|
|
29
|
+
const PERM_MODE = PERMISSION_MODE || 'bypassPermissions';
|
|
30
|
+
const EXTRA = Array.isArray(EXTRA_ARGS) ? EXTRA_ARGS : [];
|
|
31
|
+
const SHOW_TOOLS = SHOW_TOOL_ACTIVITY !== false;
|
|
32
|
+
|
|
33
|
+
// Mirroring / auto-topic behavior (all optional, sensible defaults).
|
|
34
|
+
const MIRROR = config.MIRROR !== false;
|
|
35
|
+
const AUTO_CREATE_TOPICS = config.AUTO_CREATE_TOPICS !== false;
|
|
36
|
+
const IDLE_INJECT_MS = (config.IDLE_INJECT_SECONDS || 15) * 1000;
|
|
37
|
+
const ACTIVE_WINDOW_MS = (config.ACTIVE_WINDOW_MIN || 30) * 60_000;
|
|
38
|
+
const PRUNE_AFTER_MS = (config.PRUNE_AFTER_DAYS || 7) * 86_400_000;
|
|
39
|
+
const PRUNE_MODE = config.PRUNE_MODE || 'close'; // "close" | "delete"
|
|
40
|
+
const POLL_MS = config.POLL_MS || 2000;
|
|
41
|
+
const MIRROR_FLUSH_MS = config.MIRROR_FLUSH_MS || 4000; // min gap between mirror posts per topic
|
|
42
|
+
// How to name topics: "generated" = a short AI slug (VS Code-tab style, e.g. telegram-topic-fix);
|
|
43
|
+
// "session-name" = Claude's derived name (documents-14); "first-message" = the opening prompt.
|
|
44
|
+
const TITLE_MODE = config.TITLE_MODE || 'generated';
|
|
45
|
+
const TITLE_MODEL = config.TITLE_MODEL || 'haiku';
|
|
46
|
+
// Auto-fork a held-open desk session into a persistent phone branch (on by default). The fork id is
|
|
47
|
+
// pre-minted and reserved before the turn spawns, the topic is atomically rebound to the branch, and
|
|
48
|
+
// held-detection ignores the gateway's own pid โ the three fixes that make this safe. Set
|
|
49
|
+
// AUTO_FORK:false to disable; replies into a held session then run with full context but don't persist.
|
|
50
|
+
const AUTO_FORK = config.AUTO_FORK !== false;
|
|
51
|
+
// After this many seconds with a desk tool call still unresolved, post a one-time notice to the
|
|
52
|
+
// topic (it may be a long-running tool OR a permission prompt sitting unanswered at the desk โ the
|
|
53
|
+
// transcript can't distinguish them, so the notice says both). 0 disables.
|
|
54
|
+
const STALL_NOTICE_MS = config.STALL_NOTICE_SECONDS === 0 ? 0 : (config.STALL_NOTICE_SECONDS || 60) * 1000;
|
|
55
|
+
// Phone approvals: when PERMISSION_MODE is anything other than bypassPermissions, injected turns
|
|
56
|
+
// route tool-permission prompts to the Telegram topic as Allow/Deny buttons instead of silently
|
|
57
|
+
// auto-denying. Unanswered requests deny after this timeout so turns can't hang forever.
|
|
58
|
+
const PHONE_APPROVALS = PERM_MODE !== 'bypassPermissions';
|
|
59
|
+
const APPROVAL_TIMEOUT_MS = (config.APPROVAL_TIMEOUT_SECONDS || 300) * 1000;
|
|
60
|
+
// /desk opens a topic's session in the desktop editor. Template's {session} is the session id.
|
|
61
|
+
// Default targets the Claude Code VS Code extension; Cursor/Windsurf users can swap the scheme.
|
|
62
|
+
const DESK_URL_TEMPLATE = config.DESK_URL_TEMPLATE || 'vscode://anthropic.claude-code/open?session={session}';
|
|
63
|
+
const DESK_OPEN_CMD = config.DESK_OPEN_CMD || 'open'; // macOS `open`; Linux users: "xdg-open"
|
|
64
|
+
|
|
65
|
+
// repoDir (resolved) -> chatId, so a session's cwd tells us which supergroup owns it.
|
|
66
|
+
function invertRepoMappings(mappings) {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [chatId, dir] of Object.entries(mappings || {})) out[resolveHome(dir)] = chatId;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Link store: sessionId <-> Telegram topic. Replaces the old sessions.json map.
|
|
74
|
+
// links.json = { "<sessionId>": { chatId, threadId, label, offset, closed } }
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
const LINKS_FILE = path.join(__dirname, 'links.json');
|
|
77
|
+
const SESSIONS_FILE = path.join(__dirname, 'sessions.json'); // legacy, migrated once
|
|
78
|
+
|
|
79
|
+
const IGNORED_FILE = path.join(__dirname, 'ignored.json');
|
|
80
|
+
let linkBySession = {}; // sessionId -> link
|
|
81
|
+
const sessionByThread = new Map(); // "chatId_threadId" -> sessionId
|
|
82
|
+
const ignoredSessions = new Set(); // sessions deliberately detached via /new โ don't re-topic
|
|
83
|
+
|
|
84
|
+
function loadIgnored(file = IGNORED_FILE, set = ignoredSessions) {
|
|
85
|
+
try { if (fs.existsSync(file)) for (const s of JSON.parse(fs.readFileSync(file, 'utf8'))) set.add(s); }
|
|
86
|
+
catch (e) { /* */ }
|
|
87
|
+
return set;
|
|
88
|
+
}
|
|
89
|
+
function persistIgnored(file = IGNORED_FILE, set = ignoredSessions) {
|
|
90
|
+
try { fs.writeFileSync(file, JSON.stringify([...set])); } catch (e) { /* */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Branch management: when a phone reply forks a held-open desk session, the ORIGINAL desk session is
|
|
94
|
+
// "superseded" โ we record the transcript size at the fork point and skip re-topicing it, UNLESS the
|
|
95
|
+
// desk keeps working on it (file grows past that point), in which case it automatically gets its own
|
|
96
|
+
// topic again. So divergence is handled without any manual /branches command.
|
|
97
|
+
const SUPERSEDED_FILE = path.join(__dirname, 'superseded.json');
|
|
98
|
+
let supersededAt = {}; // sessionId -> transcript size when it was forked away from
|
|
99
|
+
function loadSuperseded() { try { if (fs.existsSync(SUPERSEDED_FILE)) supersededAt = JSON.parse(fs.readFileSync(SUPERSEDED_FILE, 'utf8')); } catch (e) { supersededAt = {}; } }
|
|
100
|
+
function persistSuperseded() { try { fs.writeFileSync(SUPERSEDED_FILE, JSON.stringify(supersededAt)); } catch (e) { /* */ } }
|
|
101
|
+
|
|
102
|
+
// Auto-resume marker: the newest phone-driven branch per repo. A shell hook reads it so opening a
|
|
103
|
+
// terminal drops you straight back into what you were doing on your phone (no `cr` needed).
|
|
104
|
+
const RESUME_MARKER = path.join(process.env.HOME, '.claude-gateway', 'resume.json');
|
|
105
|
+
function writeResumeMarker(repoDir, sessionId) {
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(path.dirname(RESUME_MARKER), { recursive: true });
|
|
108
|
+
let m = {}; try { m = JSON.parse(fs.readFileSync(RESUME_MARKER, 'utf8')); } catch (e) { /* */ }
|
|
109
|
+
m[repoDir] = { sessionId, ts: Date.now() };
|
|
110
|
+
fs.writeFileSync(RESUME_MARKER, JSON.stringify(m, null, 2));
|
|
111
|
+
} catch (e) { /* */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only real interactive sessions (with an actual user message) get a topic. This filters out
|
|
115
|
+
// sub-agent/sidechain and empty/command-only session files that would otherwise spawn junk topics.
|
|
116
|
+
function shouldAutoCreate(info) { return !!(info && info.label && info.label.trim()); }
|
|
117
|
+
|
|
118
|
+
function splitThreadKey(key) { const i = key.lastIndexOf('_'); return [key.slice(0, i), key.slice(i + 1)]; }
|
|
119
|
+
|
|
120
|
+
function buildThreadIndex(links) {
|
|
121
|
+
const m = new Map();
|
|
122
|
+
for (const [sid, l] of Object.entries(links)) m.set(`${l.chatId}_${l.threadId}`, sid);
|
|
123
|
+
return m;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function migrateLegacy(links, legacy) {
|
|
127
|
+
for (const [threadKey, sid] of Object.entries(legacy || {})) {
|
|
128
|
+
if (links[sid]) continue;
|
|
129
|
+
const [chatId, threadId] = splitThreadKey(threadKey);
|
|
130
|
+
if (chatId && threadId) links[sid] = { chatId, threadId: Number(threadId), label: '', offset: 0, closed: false };
|
|
131
|
+
}
|
|
132
|
+
return links;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadLinks() {
|
|
136
|
+
try { if (fs.existsSync(LINKS_FILE)) linkBySession = JSON.parse(fs.readFileSync(LINKS_FILE, 'utf8')); }
|
|
137
|
+
catch (e) { console.warn('Could not read links.json, starting fresh:', e.message); linkBySession = {}; }
|
|
138
|
+
if (fs.existsSync(SESSIONS_FILE)) {
|
|
139
|
+
try { migrateLegacy(linkBySession, JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'))); } catch (e) { /* */ }
|
|
140
|
+
}
|
|
141
|
+
// Any link with no committed offset (freshly migrated) must start from the CURRENT end of the
|
|
142
|
+
// transcript โ otherwise the first mirror poll would replay the entire history into the topic.
|
|
143
|
+
for (const [sid, l] of Object.entries(linkBySession)) {
|
|
144
|
+
if (!l.offset) {
|
|
145
|
+
const f = sessionFileById(sid);
|
|
146
|
+
try { l.offset = f ? fs.statSync(f).size : 0; } catch (e) { l.offset = 0; }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const idx = buildThreadIndex(linkBySession);
|
|
150
|
+
sessionByThread.clear();
|
|
151
|
+
for (const [k, v] of idx) sessionByThread.set(k, v);
|
|
152
|
+
persistLinks();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function persistLinks() {
|
|
156
|
+
try { fs.writeFileSync(LINKS_FILE, JSON.stringify(linkBySession, null, 2)); }
|
|
157
|
+
catch (e) { console.error('Failed to persist links.json:', e.message); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Per-thread serialization: one Claude turn at a time per topic.
|
|
161
|
+
const threadChains = new Map();
|
|
162
|
+
const injecting = new Set(); // sessionIds the gateway is currently driving (suppress mirror)
|
|
163
|
+
const queues = new Map(); // sessionId -> [prompt] awaiting an idle desk session
|
|
164
|
+
const lastMirrorAt = new Map(); // sessionId -> ts of last mirror post (rate-limit coalescing)
|
|
165
|
+
|
|
166
|
+
// Growth baseline: a session only earns a topic once its transcript grows PAST its size when the
|
|
167
|
+
// gateway started. This stops a restart (or a `resume`/read that merely bumps mtime) from
|
|
168
|
+
// mass-creating topics for pre-existing sessions โ only genuinely-progressing work gets a topic.
|
|
169
|
+
const sessionBaseline = {}; // sessionId -> transcript size at startup (new files default 0)
|
|
170
|
+
function snapshotBaseline() {
|
|
171
|
+
for (const f of allSessionFiles()) {
|
|
172
|
+
try { sessionBaseline[path.basename(f, '.jsonl')] = fs.statSync(f).size; } catch (e) { /* */ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Helpers
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
function resolveHome(filepath) {
|
|
180
|
+
if (filepath.startsWith('~/') || filepath === '~') return path.join(process.env.HOME, filepath.slice(1));
|
|
181
|
+
return filepath;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const repoToChat = invertRepoMappings(REPO_MAPPINGS);
|
|
185
|
+
|
|
186
|
+
// --- Session discovery -----------------------------------------------------
|
|
187
|
+
const CONTENT_SEARCH_MAX_BYTES = 3_000_000;
|
|
188
|
+
|
|
189
|
+
function allSessionFiles() {
|
|
190
|
+
const projectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
191
|
+
if (!fs.existsSync(projectsDir)) return [];
|
|
192
|
+
const gather = (dir) => {
|
|
193
|
+
let out = [];
|
|
194
|
+
for (const f of fs.readdirSync(dir)) {
|
|
195
|
+
const p = path.join(dir, f);
|
|
196
|
+
let st; try { st = fs.statSync(p); } catch (e) { continue; }
|
|
197
|
+
if (st.isDirectory()) out = out.concat(gather(p));
|
|
198
|
+
else if (f.endsWith('.jsonl')) out.push(p);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
return gather(projectsDir);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const cwdCache = new Map(); // filePath -> cwd (immutable per session)
|
|
206
|
+
function getCwd(file) {
|
|
207
|
+
if (cwdCache.has(file)) return Promise.resolve(cwdCache.get(file));
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
const rl = require('readline').createInterface({ input: fs.createReadStream(file) });
|
|
210
|
+
let found = false;
|
|
211
|
+
rl.on('line', (line) => {
|
|
212
|
+
if (found) return;
|
|
213
|
+
let o; try { o = JSON.parse(line); } catch (e) { return; }
|
|
214
|
+
if (o.cwd) { found = true; cwdCache.set(file, o.cwd); rl.close(); resolve(o.cwd); }
|
|
215
|
+
});
|
|
216
|
+
rl.on('close', () => { if (!found) resolve(null); });
|
|
217
|
+
rl.on('error', () => resolve(null));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sessionFileById(sessionId) {
|
|
222
|
+
for (const file of cwdCache.keys()) if (path.basename(file, '.jsonl') === sessionId) return file;
|
|
223
|
+
for (const f of allSessionFiles()) if (path.basename(f, '.jsonl') === sessionId) return f;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Stream a session file just far enough to read its cwd + first real user message.
|
|
228
|
+
function readSessionInfo(file) {
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
let cwd = null, label = null, size = 0, mtime = 0;
|
|
231
|
+
try { const st = fs.statSync(file); size = st.size; mtime = st.mtimeMs; } catch (e) { /* */ }
|
|
232
|
+
const rl = require('readline').createInterface({ input: fs.createReadStream(file) });
|
|
233
|
+
rl.on('line', (line) => {
|
|
234
|
+
if (!line.trim()) return;
|
|
235
|
+
let o; try { o = JSON.parse(line); } catch (e) { return; }
|
|
236
|
+
if (!cwd && o.cwd) cwd = o.cwd;
|
|
237
|
+
if (!label && o.type === 'user' && o.message) {
|
|
238
|
+
const c = o.message.content;
|
|
239
|
+
let t = typeof c === 'string' ? c : (Array.isArray(c) ? (c.find((b) => b.type === 'text') || {}).text : null);
|
|
240
|
+
if (t && !t.startsWith('<') && !o.isMeta) label = t.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
241
|
+
}
|
|
242
|
+
if (cwd && label) rl.close();
|
|
243
|
+
});
|
|
244
|
+
rl.on('close', () => resolve({ id: path.basename(file, '.jsonl'), path: file, cwd, label, size, mtime }));
|
|
245
|
+
rl.on('error', () => resolve({ id: path.basename(file, '.jsonl'), path: file, cwd, label, size, mtime }));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function listSessions(repoDir) {
|
|
250
|
+
const target = resolveHome(repoDir);
|
|
251
|
+
const infos = await Promise.all(allSessionFiles().map(readSessionInfo));
|
|
252
|
+
return infos.filter((s) => s.cwd === target).sort((a, b) => b.mtime - a.mtime);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function matchSessions(repoDir, term) {
|
|
256
|
+
const t = term.toLowerCase();
|
|
257
|
+
const sessions = await listSessions(repoDir);
|
|
258
|
+
const byLabel = sessions.filter((s) => (s.label || '').toLowerCase().includes(t) || s.id.toLowerCase().startsWith(t));
|
|
259
|
+
if (byLabel.length) return byLabel;
|
|
260
|
+
return sessions.filter((s) => {
|
|
261
|
+
if (s.size > CONTENT_SEARCH_MAX_BYTES) return false;
|
|
262
|
+
try { return fs.readFileSync(s.path, 'utf8').toLowerCase().includes(t); } catch (e) { return false; }
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function relTime(ms) {
|
|
267
|
+
const s = (Date.now() - ms) / 1000;
|
|
268
|
+
if (s < 90) return 'just now';
|
|
269
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
270
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
271
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function formatSessionList(sessions, max = 12) {
|
|
275
|
+
return sessions.slice(0, max).map((s) =>
|
|
276
|
+
`โข ${s.label || '(no first message)'}\n ${relTime(s.mtime)} โ id: ${s.id}`
|
|
277
|
+
).join('\n\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- Activity windows (pure) ----------------------------------------------
|
|
281
|
+
function isActive(mtime, now = Date.now()) { return (now - mtime) <= ACTIVE_WINDOW_MS; }
|
|
282
|
+
function shouldPrune(mtime, now = Date.now()) { return (now - mtime) > PRUNE_AFTER_MS; }
|
|
283
|
+
function isDeskBusy(mtime, now = Date.now()) { return (now - mtime) <= IDLE_INJECT_MS; }
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Telegram API
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
function telegramRequest(method, payload) {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const data = JSON.stringify(payload);
|
|
291
|
+
const options = {
|
|
292
|
+
hostname: 'api.telegram.org', port: 443,
|
|
293
|
+
path: `/bot${BOT_TOKEN}/${method}`, method: 'POST',
|
|
294
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
|
|
295
|
+
};
|
|
296
|
+
const req = https.request(options, (res) => {
|
|
297
|
+
let body = '';
|
|
298
|
+
res.on('data', (c) => (body += c));
|
|
299
|
+
res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } });
|
|
300
|
+
});
|
|
301
|
+
req.setTimeout(15000, () => req.destroy(new Error('telegram request timeout'))); // fail fast; a hung send must not stall the poll loop
|
|
302
|
+
req.on('error', reject);
|
|
303
|
+
req.write(data);
|
|
304
|
+
req.end();
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function sendPlain(chatId, threadId, text) {
|
|
309
|
+
const MAX = 4000;
|
|
310
|
+
let rest = text;
|
|
311
|
+
const chunks = [];
|
|
312
|
+
while (rest.length > MAX) {
|
|
313
|
+
let cut = rest.lastIndexOf('\n', MAX);
|
|
314
|
+
if (cut < MAX * 0.5) cut = MAX;
|
|
315
|
+
chunks.push(rest.slice(0, cut));
|
|
316
|
+
rest = rest.slice(cut).replace(/^\s+/, '');
|
|
317
|
+
}
|
|
318
|
+
if (rest) chunks.push(rest);
|
|
319
|
+
let allSent = true;
|
|
320
|
+
for (const c of chunks) {
|
|
321
|
+
try {
|
|
322
|
+
const r = await telegramRequest('sendMessage', { chat_id: chatId, message_thread_id: threadId, text: c });
|
|
323
|
+
if (!r || !r.ok) allSent = false;
|
|
324
|
+
} catch (e) { console.error('sendPlain error:', e.code || e.message || String(e)); allSent = false; }
|
|
325
|
+
}
|
|
326
|
+
return allSent; // callers that mirror content use this to avoid advancing past unsent lines
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function startTyping(chatId, threadId) {
|
|
330
|
+
const ping = () => telegramRequest('sendChatAction', { chat_id: chatId, message_thread_id: threadId, action: 'typing' }).catch(() => {});
|
|
331
|
+
ping();
|
|
332
|
+
return setInterval(ping, 4000);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Phone approvals: registry + Telegram buttons ---------------------------
|
|
336
|
+
// When PERMISSION_MODE isn't bypassPermissions, injected turns pause on tool permissions
|
|
337
|
+
// (can_use_tool control requests) and route them here as Allow/Deny inline buttons.
|
|
338
|
+
function createApprovalRegistry() {
|
|
339
|
+
let seq = 0;
|
|
340
|
+
const pending = new Map(); // id -> { resolve, timer, meta }
|
|
341
|
+
return {
|
|
342
|
+
create(meta, timeoutMs) {
|
|
343
|
+
const id = String(++seq);
|
|
344
|
+
let resolveFn;
|
|
345
|
+
const promise = new Promise((res) => { resolveFn = res; });
|
|
346
|
+
const entry = { meta, timer: null, resolve: (r) => { if (entry.timer) clearTimeout(entry.timer); pending.delete(id); resolveFn(r); } };
|
|
347
|
+
if (timeoutMs) entry.timer = setTimeout(() => entry.resolve({ allowed: false, timedOut: true }), timeoutMs);
|
|
348
|
+
pending.set(id, entry);
|
|
349
|
+
return { id, promise };
|
|
350
|
+
},
|
|
351
|
+
resolve(id, allowed, by) {
|
|
352
|
+
const e = pending.get(id);
|
|
353
|
+
if (!e) return null; // already handled / timed out
|
|
354
|
+
const meta = e.meta;
|
|
355
|
+
e.resolve({ allowed, by });
|
|
356
|
+
return meta;
|
|
357
|
+
},
|
|
358
|
+
size() { return pending.size; },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const approvals = createApprovalRegistry();
|
|
362
|
+
|
|
363
|
+
async function sendApprovalRequest(chatId, threadId, toolName, summary, approvalId) {
|
|
364
|
+
const r = await telegramRequest('sendMessage', {
|
|
365
|
+
chat_id: chatId, message_thread_id: threadId,
|
|
366
|
+
text: `๐ Permission request:\n${toolName}${summary ? ': ' + summary : ''}`,
|
|
367
|
+
reply_markup: { inline_keyboard: [[
|
|
368
|
+
{ text: 'โ
Allow', callback_data: `ap:${approvalId}:1` },
|
|
369
|
+
{ text: 'โ Deny', callback_data: `ap:${approvalId}:0` },
|
|
370
|
+
]] },
|
|
371
|
+
}).catch(() => null);
|
|
372
|
+
return r && r.ok ? r.result.message_id : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function createForumTopic(chatId, name) {
|
|
376
|
+
const r = await telegramRequest('createForumTopic', { chat_id: chatId, name: name.slice(0, 128) })
|
|
377
|
+
.catch((e) => ({ ok: false, description: e.message }));
|
|
378
|
+
if (!r.ok) { console.error(`[Topic] createForumTopic failed (${r.description || 'unknown'}). Bot needs admin + Manage Topics.`); return null; }
|
|
379
|
+
return r.result.message_thread_id;
|
|
380
|
+
}
|
|
381
|
+
const closeForumTopic = (chatId, threadId) => telegramRequest('closeForumTopic', { chat_id: chatId, message_thread_id: threadId }).catch(() => {});
|
|
382
|
+
const reopenForumTopic = (chatId, threadId) => telegramRequest('reopenForumTopic', { chat_id: chatId, message_thread_id: threadId }).catch(() => {});
|
|
383
|
+
const deleteForumTopic = (chatId, threadId) => telegramRequest('deleteForumTopic', { chat_id: chatId, message_thread_id: threadId }).catch(() => {});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// LiveMessage (unchanged): in-place, throttled editing with page rollover.
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
const PAGE_MAX = 3800;
|
|
389
|
+
const EDIT_INTERVAL_MS = 1500;
|
|
390
|
+
|
|
391
|
+
class LiveMessage {
|
|
392
|
+
constructor(chatId, threadId) {
|
|
393
|
+
this.chatId = chatId;
|
|
394
|
+
this.threadId = threadId;
|
|
395
|
+
this.frozenLen = 0;
|
|
396
|
+
this.curId = null;
|
|
397
|
+
this.sentForCur = '';
|
|
398
|
+
this.lastEditAt = 0;
|
|
399
|
+
this.flushTimer = null;
|
|
400
|
+
this.running = null;
|
|
401
|
+
this.pending = null;
|
|
402
|
+
this.forceNext = false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async _sendNew(text) {
|
|
406
|
+
const r = await telegramRequest('sendMessage', {
|
|
407
|
+
chat_id: this.chatId, message_thread_id: this.threadId, text: text || 'โฆ'
|
|
408
|
+
}).catch((e) => { console.error('live send error:', e.message); return null; });
|
|
409
|
+
return r && r.ok ? r.result.message_id : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async _editCur(text) {
|
|
413
|
+
if (this.curId == null || text === this.sentForCur) return;
|
|
414
|
+
this.sentForCur = text;
|
|
415
|
+
this.lastEditAt = Date.now();
|
|
416
|
+
await telegramRequest('editMessageText', {
|
|
417
|
+
chat_id: this.chatId, message_id: this.curId, text: text || 'โฆ'
|
|
418
|
+
}).catch(() => { /* "message not modified" etc. */ });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_splitPoint(text) {
|
|
422
|
+
if (text.length <= PAGE_MAX) return text.length;
|
|
423
|
+
let cut = text.lastIndexOf('\n', PAGE_MAX);
|
|
424
|
+
if (cut < PAGE_MAX * 0.5) cut = text.lastIndexOf(' ', PAGE_MAX);
|
|
425
|
+
if (cut < PAGE_MAX * 0.5) cut = PAGE_MAX;
|
|
426
|
+
return cut;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
set(full, force = false) {
|
|
430
|
+
this.pending = full;
|
|
431
|
+
if (force) this.forceNext = true;
|
|
432
|
+
if (this.running) return this.running;
|
|
433
|
+
this.running = (async () => {
|
|
434
|
+
while (this.pending != null) {
|
|
435
|
+
const f = this.pending; this.pending = null;
|
|
436
|
+
const force2 = this.forceNext; this.forceNext = false;
|
|
437
|
+
await this._apply(f, force2);
|
|
438
|
+
}
|
|
439
|
+
this.running = null;
|
|
440
|
+
})();
|
|
441
|
+
return this.running;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async _apply(full, force) {
|
|
445
|
+
let tail = full.slice(this.frozenLen);
|
|
446
|
+
while (tail.length > PAGE_MAX) {
|
|
447
|
+
const cut = this._splitPoint(tail);
|
|
448
|
+
const chunk = tail.slice(0, cut);
|
|
449
|
+
if (this.curId == null) this.curId = await this._sendNew(chunk);
|
|
450
|
+
else await this._editCur(chunk);
|
|
451
|
+
this.frozenLen += chunk.length;
|
|
452
|
+
this.curId = null;
|
|
453
|
+
this.sentForCur = '';
|
|
454
|
+
tail = full.slice(this.frozenLen);
|
|
455
|
+
}
|
|
456
|
+
if (this.curId == null) {
|
|
457
|
+
if (tail.trim()) { this.curId = await this._sendNew(tail); this.sentForCur = tail; }
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const since = Date.now() - this.lastEditAt;
|
|
461
|
+
if (!force && since < EDIT_INTERVAL_MS) {
|
|
462
|
+
if (!this.flushTimer) this.flushTimer = setTimeout(() => { this.flushTimer = null; this.set(full); }, EDIT_INTERVAL_MS - since);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; }
|
|
466
|
+
await this._editCur(tail);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async finalize(full) {
|
|
470
|
+
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; }
|
|
471
|
+
await this.set(full, true);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// Rendering: streamed events (createFeed) and stored transcript lines.
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
function summarizeToolInput(name, input) {
|
|
479
|
+
if (!input || typeof input !== 'object') return '';
|
|
480
|
+
if (name === 'Bash' && input.command) return input.command.replace(/\s+/g, ' ').slice(0, 120);
|
|
481
|
+
const key = input.file_path || input.path || input.pattern || input.query || input.url;
|
|
482
|
+
if (key) return String(key).slice(0, 120);
|
|
483
|
+
const s = JSON.stringify(input);
|
|
484
|
+
return s.length > 100 ? s.slice(0, 100) + 'โฆ' : s;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createFeed(showTools = true) {
|
|
488
|
+
let body = '';
|
|
489
|
+
const feed = {
|
|
490
|
+
sawContent: false, sessionId: null, isError: false, resultText: null,
|
|
491
|
+
render() { return body.trim() || 'โ๏ธ Workingโฆ'; },
|
|
492
|
+
handle(o) {
|
|
493
|
+
if (!o || typeof o !== 'object') return false;
|
|
494
|
+
if (o.type === 'stream_event' && o.event && o.event.type === 'content_block_delta'
|
|
495
|
+
&& o.event.delta && o.event.delta.type === 'text_delta') {
|
|
496
|
+
body += o.event.delta.text; feed.sawContent = true; return true;
|
|
497
|
+
}
|
|
498
|
+
if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
499
|
+
let changed = false;
|
|
500
|
+
for (const block of o.message.content) {
|
|
501
|
+
if (block.type === 'tool_use' && showTools) {
|
|
502
|
+
const summary = summarizeToolInput(block.name, block.input);
|
|
503
|
+
if (body && !body.endsWith('\n')) body += '\n';
|
|
504
|
+
body += `๐ง ${block.name}${summary ? ': ' + summary : ''}\n`;
|
|
505
|
+
feed.sawContent = true; changed = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return changed;
|
|
509
|
+
}
|
|
510
|
+
if (o.type === 'result') {
|
|
511
|
+
feed.sessionId = o.session_id || null;
|
|
512
|
+
feed.isError = !!o.is_error || o.subtype !== 'success';
|
|
513
|
+
feed.resultText = typeof o.result === 'string' ? o.result : null;
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
},
|
|
517
|
+
finish() { if (feed.resultText && !feed.sawContent) body = feed.resultText; return feed.render(); }
|
|
518
|
+
};
|
|
519
|
+
return feed;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// A stored transcript record -> zero or more Telegram post strings.
|
|
523
|
+
function renderTranscriptLine(o, showTools = true) {
|
|
524
|
+
if (!o || typeof o !== 'object') return [];
|
|
525
|
+
if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
526
|
+
const out = [];
|
|
527
|
+
for (const b of o.message.content) {
|
|
528
|
+
if (b.type === 'text' && b.text && b.text.trim()) out.push(b.text.trim());
|
|
529
|
+
else if (b.type === 'tool_use' && showTools) {
|
|
530
|
+
const s = summarizeToolInput(b.name, b.input);
|
|
531
|
+
out.push(`๐ง ${b.name}${s ? ': ' + s : ''}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
if (o.type === 'user' && !o.isMeta && o.message) {
|
|
537
|
+
const c = o.message.content;
|
|
538
|
+
const t = typeof c === 'string' ? c : (Array.isArray(c) ? (c.find((x) => x.type === 'text') || {}).text : null);
|
|
539
|
+
if (t && !t.startsWith('<') && t.trim()) return [`๐ฅ๏ธ desk: ${t.replace(/\s+/g, ' ').trim()}`];
|
|
540
|
+
// Surface tool errors (but not the noisy success output) so a failing desk run is visible.
|
|
541
|
+
if (Array.isArray(c) && showTools) {
|
|
542
|
+
const errs = [];
|
|
543
|
+
for (const b of c) {
|
|
544
|
+
if (b.type === 'tool_result' && b.is_error) {
|
|
545
|
+
const et = typeof b.content === 'string' ? b.content : (Array.isArray(b.content) ? (b.content.find((x) => x.type === 'text') || {}).text : '');
|
|
546
|
+
errs.push(`โ ๏ธ tool error: ${(et || '').replace(/\s+/g, ' ').trim().slice(0, 150)}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return errs;
|
|
550
|
+
}
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The last user prompt + assistant response in a transcript, so a freshly-created topic shows where
|
|
557
|
+
// the session left off. Reads only the tail of the file to stay cheap on large transcripts.
|
|
558
|
+
function lastExchange(file) {
|
|
559
|
+
try {
|
|
560
|
+
const size = fs.statSync(file).size;
|
|
561
|
+
const start = Math.max(0, size - 131072); // last 128 KB is plenty for the final turn
|
|
562
|
+
const len = size - start;
|
|
563
|
+
const buf = Buffer.alloc(len);
|
|
564
|
+
const fd = fs.openSync(file, 'r');
|
|
565
|
+
try { fs.readSync(fd, buf, 0, len, start); } finally { fs.closeSync(fd); }
|
|
566
|
+
let lastText = null, lastUser = null;
|
|
567
|
+
for (const line of buf.toString('utf8').split('\n')) {
|
|
568
|
+
if (!line.trim()) continue;
|
|
569
|
+
let o; try { o = JSON.parse(line); } catch (e) { continue; }
|
|
570
|
+
if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
571
|
+
const t = o.message.content.filter((b) => b.type === 'text' && b.text && b.text.trim()).map((b) => b.text.trim()).join('\n');
|
|
572
|
+
if (t) lastText = t;
|
|
573
|
+
} else if (o.type === 'user' && !o.isMeta && o.message) {
|
|
574
|
+
const c = o.message.content;
|
|
575
|
+
const t = typeof c === 'string' ? c : (Array.isArray(c) ? (c.find((x) => x.type === 'text') || {}).text : null);
|
|
576
|
+
if (t && !t.startsWith('<') && t.trim()) lastUser = t.replace(/\s+/g, ' ').trim();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { lastText, lastUser };
|
|
580
|
+
} catch (e) { return { lastText: null, lastUser: null }; }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Read complete JSONL records appended since `offset`. Returns parsed lines + advanced offset.
|
|
584
|
+
function readNewLines(filePath, offset) {
|
|
585
|
+
let size;
|
|
586
|
+
try { size = fs.statSync(filePath).size; } catch (e) { return { lines: [], newOffset: offset }; }
|
|
587
|
+
if (size <= offset) return { lines: [], newOffset: offset };
|
|
588
|
+
const len = size - offset;
|
|
589
|
+
const buf = Buffer.alloc(len);
|
|
590
|
+
let fd;
|
|
591
|
+
try { fd = fs.openSync(filePath, 'r'); fs.readSync(fd, buf, 0, len, offset); }
|
|
592
|
+
catch (e) { return { lines: [], newOffset: offset }; }
|
|
593
|
+
finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
594
|
+
const text = buf.toString('utf8');
|
|
595
|
+
const lastNl = text.lastIndexOf('\n');
|
|
596
|
+
if (lastNl === -1) return { lines: [], newOffset: offset }; // no complete line yet
|
|
597
|
+
const complete = text.slice(0, lastNl);
|
|
598
|
+
const newOffset = offset + Buffer.byteLength(complete, 'utf8') + 1;
|
|
599
|
+
const lines = [];
|
|
600
|
+
for (const line of complete.split('\n')) {
|
|
601
|
+
if (!line.trim()) continue;
|
|
602
|
+
let o; try { o = JSON.parse(line); } catch (e) { continue; }
|
|
603
|
+
lines.push(o);
|
|
604
|
+
}
|
|
605
|
+
return { lines, newOffset };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Running a Claude turn (streaming headless) โ used for phone injections.
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
function runClaudeTurn(prompt, cwd, sessionId, live, createId, forkId, onPermission) {
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose',
|
|
614
|
+
'--permission-mode', PERM_MODE];
|
|
615
|
+
// Non-bypass modes: route tool-permission prompts to us over stdio (verified: the CLI emits
|
|
616
|
+
// control_request/can_use_tool and pauses the tool until our control_response arrives).
|
|
617
|
+
if (PHONE_APPROVALS) args.push('--permission-prompt-tool', 'stdio', '--input-format', 'stream-json');
|
|
618
|
+
if (MODEL) args.push('--model', MODEL);
|
|
619
|
+
if (sessionId) {
|
|
620
|
+
args.push('--resume', sessionId);
|
|
621
|
+
// Fork with a PRE-MINTED id (verified supported) so the fork is reserved in `injecting`
|
|
622
|
+
// before it ever appears on disk โ the poller can never race it into a duplicate topic.
|
|
623
|
+
if (forkId) args.push('--fork-session', '--session-id', forkId);
|
|
624
|
+
} else if (createId) args.push('--session-id', createId); // deterministic id for a fresh session
|
|
625
|
+
args.push(...EXTRA);
|
|
626
|
+
|
|
627
|
+
console.log(`[Claude] ${sessionId ? 'resume ' + sessionId : 'new session'} in ${cwd}`);
|
|
628
|
+
const child = spawn(CLAUDE_BINARY, args, { cwd, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
629
|
+
const feed = createFeed(SHOW_TOOLS);
|
|
630
|
+
let stderr = '', rem = '';
|
|
631
|
+
|
|
632
|
+
const answerPermission = async (o) => {
|
|
633
|
+
let resp = { behavior: 'deny', message: 'No approval handler available' };
|
|
634
|
+
try { if (onPermission) resp = await onPermission(o.request); }
|
|
635
|
+
catch (e) { resp = { behavior: 'deny', message: `Approval handler error: ${e.message}` }; }
|
|
636
|
+
try { child.stdin.write(JSON.stringify({ type: 'control_response', response: { subtype: 'success', request_id: o.request_id, response: resp } }) + '\n'); }
|
|
637
|
+
catch (e) { /* child gone */ }
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
child.stdout.on('data', (d) => {
|
|
641
|
+
rem += d.toString();
|
|
642
|
+
const lines = rem.split('\n');
|
|
643
|
+
rem = lines.pop();
|
|
644
|
+
for (const line of lines) {
|
|
645
|
+
if (!line.trim()) continue;
|
|
646
|
+
let o; try { o = JSON.parse(line); } catch (e) { continue; }
|
|
647
|
+
if (o.type === 'control_request' && o.request && o.request.subtype === 'can_use_tool') { answerPermission(o); continue; }
|
|
648
|
+
try { if (feed.handle(o)) live.set(feed.render()); } catch (e) { console.error('event handler error:', e.message); }
|
|
649
|
+
// In stream-json input mode the CLI waits for more input after the result โ close stdin to let it exit.
|
|
650
|
+
if (o.type === 'result' && PHONE_APPROVALS) { try { child.stdin.end(); } catch (e) { /* */ } }
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
654
|
+
child.on('error', (err) => resolve({ ok: false, error: `Failed to launch Claude: ${err.message}`, sawContent: feed.sawContent }));
|
|
655
|
+
child.on('close', () => {
|
|
656
|
+
const body = feed.finish();
|
|
657
|
+
if (feed.isError) {
|
|
658
|
+
const msg = feed.resultText || stderr.trim().slice(0, 400) || 'Claude returned an error.';
|
|
659
|
+
return resolve({ ok: false, error: msg, sessionId: feed.sessionId, sawContent: feed.sawContent, body });
|
|
660
|
+
}
|
|
661
|
+
resolve({ ok: true, sessionId: feed.sessionId, sawContent: feed.sawContent, body });
|
|
662
|
+
});
|
|
663
|
+
if (PHONE_APPROVALS) {
|
|
664
|
+
// stdin must STAY OPEN for control responses; closed after the result arrives (above).
|
|
665
|
+
child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } }) + '\n');
|
|
666
|
+
} else {
|
|
667
|
+
child.stdin.write(prompt);
|
|
668
|
+
child.stdin.end();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Bind (or rebind) a session to a Telegram thread; reconcile a duplicate auto-topic if any.
|
|
674
|
+
async function upsertLink(sessionId, chatId, threadId, labelHint) {
|
|
675
|
+
const existing = linkBySession[sessionId];
|
|
676
|
+
if (existing && (existing.chatId !== chatId || existing.threadId !== threadId)) {
|
|
677
|
+
// A different topic (likely auto-created by the poller) already owns this session โ close it.
|
|
678
|
+
sessionByThread.delete(`${existing.chatId}_${existing.threadId}`);
|
|
679
|
+
closeForumTopic(existing.chatId, existing.threadId);
|
|
680
|
+
}
|
|
681
|
+
const link = existing && existing.chatId === chatId && existing.threadId === threadId
|
|
682
|
+
? existing
|
|
683
|
+
: { chatId, threadId, label: '', offset: 0, closed: false };
|
|
684
|
+
link.chatId = chatId; link.threadId = threadId; link.closed = false;
|
|
685
|
+
if (!link.label && labelHint) link.label = labelHint.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
686
|
+
linkBySession[sessionId] = link;
|
|
687
|
+
sessionByThread.set(`${chatId}_${threadId}`, sessionId);
|
|
688
|
+
ignoredSessions.delete(sessionId);
|
|
689
|
+
return link;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// --- Desk stall / approval notices -----------------------------------------
|
|
693
|
+
// A desk permission prompt is UI state and never appears in the transcript โ the session just goes
|
|
694
|
+
// quiet after an assistant tool_use with no tool_result. We track unresolved tool calls per session
|
|
695
|
+
// and, past a threshold, post one honest notice (could be a slow tool OR an unanswered prompt),
|
|
696
|
+
// plus a resolution line when it completes so the phone knows the session is moving again.
|
|
697
|
+
const pendingTools = {}; // sessionId -> { toolUseId: { name, summary, ts, notified } }
|
|
698
|
+
|
|
699
|
+
// Pure: fold mirrored transcript records into one session's pending-tool state.
|
|
700
|
+
// Returns entries that had been notified and just resolved (worth announcing).
|
|
701
|
+
function updatePendingTools(state, records, now) {
|
|
702
|
+
const resolved = [];
|
|
703
|
+
for (const o of records) {
|
|
704
|
+
if (!o || typeof o !== 'object') continue;
|
|
705
|
+
if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
706
|
+
for (const b of o.message.content) {
|
|
707
|
+
if (b.type === 'tool_use' && b.id) state[b.id] = { name: b.name, summary: summarizeToolInput(b.name, b.input), ts: now, notified: false };
|
|
708
|
+
}
|
|
709
|
+
} else if (o.type === 'user' && o.message && Array.isArray(o.message.content)) {
|
|
710
|
+
for (const b of o.message.content) {
|
|
711
|
+
if (b.type === 'tool_result' && b.tool_use_id && state[b.tool_use_id]) {
|
|
712
|
+
if (state[b.tool_use_id].notified) resolved.push(state[b.tool_use_id]);
|
|
713
|
+
delete state[b.tool_use_id];
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return resolved;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Pure: entries past the threshold not yet announced (marks them announced).
|
|
722
|
+
function dueStallNotices(state, now, thresholdMs) {
|
|
723
|
+
const due = [];
|
|
724
|
+
if (!thresholdMs || !state) return due;
|
|
725
|
+
for (const e of Object.values(state)) {
|
|
726
|
+
if (!e.notified && now - e.ts >= thresholdMs) { e.notified = true; due.push(e); }
|
|
727
|
+
}
|
|
728
|
+
return due;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// A resumed turn "sticks" only if the transcript grew. If it didn't, the desk TUI is holding the
|
|
732
|
+
// session open and the injected turn ran in memory only (verified behavior) โ nothing was saved.
|
|
733
|
+
function persisted(sizeBefore, sizeAfter) { return sizeAfter > sizeBefore; }
|
|
734
|
+
function sizeCurrent(sessionId) { try { return fs.statSync(sessionFileById(sessionId)).size; } catch (e) { return 0; } }
|
|
735
|
+
|
|
736
|
+
// Drive one turn in `threadId` on behalf of `knownSessionId` (or a fresh session if null).
|
|
737
|
+
// Build the desktop deep-link that opens a session in the editor (pure โ tested).
|
|
738
|
+
function deskUrl(sessionId) { return DESK_URL_TEMPLATE.replace('{session}', encodeURIComponent(sessionId)); }
|
|
739
|
+
// Open a session in the desktop editor on the Mac (the gateway runs there).
|
|
740
|
+
function openOnDesk(sessionId) {
|
|
741
|
+
try { execFileSync(DESK_OPEN_CMD, [deskUrl(sessionId)], { stdio: 'ignore' }); return true; }
|
|
742
|
+
catch (e) { console.error('openOnDesk failed:', e.message); return false; }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Is the transcript currently held open by ANOTHER process (the desk TUI / VS Code extension)?
|
|
746
|
+
// Verified: a live TUI keeps its .jsonl open even when idle, so lsof detects it โ letting us decide
|
|
747
|
+
// fork-vs-resume BEFORE running, so the prompt (and its side effects) never runs twice.
|
|
748
|
+
// CRITICAL: exclude our own pid โ the gateway's own transient read streams (mirror/label scans)
|
|
749
|
+
// otherwise register as "held" and caused spurious chained forks.
|
|
750
|
+
function heldByOtherPids(lsofOutput, selfPid) {
|
|
751
|
+
return lsofOutput.split('\n').map((s) => parseInt(s.trim(), 10)).filter(Boolean).filter((pid) => pid !== selfPid);
|
|
752
|
+
}
|
|
753
|
+
function isSessionHeld(file) {
|
|
754
|
+
if (!file) return false;
|
|
755
|
+
try {
|
|
756
|
+
const out = execFileSync('lsof', ['-t', file], { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
757
|
+
return heldByOtherPids(out, process.pid).length > 0;
|
|
758
|
+
} catch (e) { return false; } // lsof exits non-zero when nothing holds the file
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// `resolveSession` is a FUNCTION evaluated when the turn actually runs โ not when the message was
|
|
762
|
+
// enqueued. Two rapid replies previously both captured the pre-fork session id and forked it twice;
|
|
763
|
+
// resolving at run time means the second reply sees the first reply's fork and continues it.
|
|
764
|
+
async function driveTurn(chatId, threadId, prompt, resolveSession) {
|
|
765
|
+
const repoDir = resolveHome(REPO_MAPPINGS[chatId]);
|
|
766
|
+
const typing = startTyping(chatId, threadId);
|
|
767
|
+
const live = new LiveMessage(chatId, threadId);
|
|
768
|
+
const sessionId = (typeof resolveSession === 'function' ? resolveSession() : resolveSession) || null;
|
|
769
|
+
|
|
770
|
+
// Decide the mode ONCE, before spawning (single run โ side effects never execute twice):
|
|
771
|
+
// held by another process + AUTO_FORK โ fork with a PRE-MINTED id (reserved below)
|
|
772
|
+
// held, no AUTO_FORK โ resume; reply has full context but won't persist
|
|
773
|
+
// free existing session โ resume in place
|
|
774
|
+
// no session โ fresh session with a pre-minted id
|
|
775
|
+
const held = !!(sessionId && isSessionHeld(sessionFileById(sessionId)));
|
|
776
|
+
const forkId = (AUTO_FORK && held) ? crypto.randomUUID() : null;
|
|
777
|
+
const createId = sessionId ? null : crypto.randomUUID();
|
|
778
|
+
const sizeBefore = sessionId ? sizeCurrent(sessionId) : 0;
|
|
779
|
+
// Reserve every id this turn may touch BEFORE spawning, so the poller can't topic any of them.
|
|
780
|
+
const reserved = [sessionId, createId, forkId].filter(Boolean);
|
|
781
|
+
reserved.forEach((id) => injecting.add(id));
|
|
782
|
+
// Non-bypass modes: tool-permission prompts become Allow/Deny buttons in this topic.
|
|
783
|
+
const onPermission = PHONE_APPROVALS ? (async (req) => {
|
|
784
|
+
const summary = summarizeToolInput(req.tool_name, req.input);
|
|
785
|
+
const { id, promise } = approvals.create({ chatId, threadId }, APPROVAL_TIMEOUT_MS);
|
|
786
|
+
const msgId = await sendApprovalRequest(chatId, threadId, req.tool_name, summary, id);
|
|
787
|
+
const res = await promise;
|
|
788
|
+
if (res.timedOut && msgId) {
|
|
789
|
+
telegramRequest('editMessageText', { chat_id: chatId, message_id: msgId,
|
|
790
|
+
text: `๐ ${req.tool_name}${summary ? ': ' + summary : ''}\n\nโฑ Timed out โ denied.` }).catch(() => {});
|
|
791
|
+
}
|
|
792
|
+
return res.allowed
|
|
793
|
+
? { behavior: 'allow', updatedInput: req.input }
|
|
794
|
+
: { behavior: 'deny', message: res.timedOut ? 'Approval timed out on Telegram' : 'Denied from Telegram' };
|
|
795
|
+
}) : null;
|
|
796
|
+
try {
|
|
797
|
+
const result = await runClaudeTurn(prompt, repoDir, sessionId, live, createId, forkId, onPermission);
|
|
798
|
+
clearInterval(typing);
|
|
799
|
+
|
|
800
|
+
if (!result.ok && sessionId && !result.sawContent && !forkId) {
|
|
801
|
+
await live.finalize(`โ ๏ธ Couldn't reach session ${sessionId.slice(0, 8)} โ it may have been cleared. ` +
|
|
802
|
+
`Send /sessions to pick another, or /new to start fresh here.`);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const body = result.ok ? result.body
|
|
807
|
+
: `${result.body && result.body !== 'โ๏ธ Workingโฆ' ? result.body + '\n\n' : ''}โ ๏ธ ${result.error}`;
|
|
808
|
+
|
|
809
|
+
if (forkId) {
|
|
810
|
+
const forked = result.ok && result.sessionId === forkId && fs.existsSync(sessionFileById(forkId) || '');
|
|
811
|
+
if (forked) {
|
|
812
|
+
// Atomic rebind: topic keeps its identity, now follows the phone branch. The desk copy is
|
|
813
|
+
// marked superseded at its current size โ if the desk keeps working it re-topics on its own.
|
|
814
|
+
supersededAt[sessionId] = sizeCurrent(sessionId); persistSuperseded();
|
|
815
|
+
delete linkBySession[sessionId];
|
|
816
|
+
await upsertLink(forkId, chatId, threadId, prompt); // overwrites threadโsession mapping
|
|
817
|
+
try { linkBySession[forkId].offset = sizeCurrent(forkId); } catch (e) { /* */ }
|
|
818
|
+
persistLinks();
|
|
819
|
+
if (queues.has(sessionId)) { queues.set(forkId, queues.get(sessionId)); queues.delete(sessionId); } // queued replies follow the fork
|
|
820
|
+
writeResumeMarker(repoDir, forkId);
|
|
821
|
+
await live.finalize(`${body}\n\nโช๏ธ Desk session was open, so this continued in a saved phone branch. ` +
|
|
822
|
+
`This topic now follows the branch; your desk copy is untouched (it gets its own topic if you keep working it there).`);
|
|
823
|
+
console.log(`[Drive โ thread ${threadId}] forked ${sessionId.slice(0, 8)} โ ${forkId.slice(0, 8)} (desk held open)`);
|
|
824
|
+
} else {
|
|
825
|
+
// Fork didn't take โ DON'T touch the binding; the reply above still had full context.
|
|
826
|
+
await live.finalize(`${body}\n\nโ ๏ธ The desk session is open and the branch didn't persist โ ` +
|
|
827
|
+
`this reply used full context but wasn't saved. Close the desk session and resend to persist.`);
|
|
828
|
+
console.log(`[Drive โ thread ${threadId}] fork failed for ${sessionId.slice(0, 8)}; binding unchanged`);
|
|
829
|
+
}
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Held-open without AUTO_FORK: full context, but nothing was saved to the desk copy.
|
|
834
|
+
const ephemeral = held && result.ok && !persisted(sizeBefore, sizeCurrent(sessionId));
|
|
835
|
+
await live.finalize(ephemeral
|
|
836
|
+
? `${body}\n\nโ ๏ธ The desk session is open, so this reply isn't saved to it (close the desk ` +
|
|
837
|
+
`session to persist). The reply above still used the full session context.`
|
|
838
|
+
: body);
|
|
839
|
+
|
|
840
|
+
const finalSid = result.sessionId || sessionId || createId;
|
|
841
|
+
if (finalSid) {
|
|
842
|
+
injecting.add(finalSid);
|
|
843
|
+
await upsertLink(finalSid, chatId, threadId, prompt);
|
|
844
|
+
if (!ephemeral) { try { linkBySession[finalSid].offset = sizeCurrent(finalSid); } catch (e) { /* */ } }
|
|
845
|
+
persistLinks();
|
|
846
|
+
if (result.ok && !ephemeral) writeResumeMarker(repoDir, finalSid);
|
|
847
|
+
if (!reserved.includes(finalSid)) reserved.push(finalSid);
|
|
848
|
+
}
|
|
849
|
+
console.log(`[Drive โ thread ${threadId}] ok=${result.ok} session=${finalSid || 'โ'}${ephemeral ? ' (held/ephemeral)' : ''}`);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
clearInterval(typing);
|
|
852
|
+
console.error('driveTurn error:', err);
|
|
853
|
+
await sendPlain(chatId, threadId, `โ ๏ธ Gateway error: ${err.message}`);
|
|
854
|
+
} finally {
|
|
855
|
+
reserved.forEach((id) => injecting.delete(id));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function scheduleDrive(chatId, threadId, prompt, resolveSession) {
|
|
860
|
+
const key = `${chatId}_${threadId}`;
|
|
861
|
+
const prev = threadChains.get(key) || Promise.resolve();
|
|
862
|
+
const next = prev.then(() => driveTurn(chatId, threadId, prompt, resolveSession)).catch((e) => console.error(e));
|
|
863
|
+
threadChains.set(key, next);
|
|
864
|
+
return next;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function queueForSession(sessionId, prompt) {
|
|
868
|
+
if (!queues.has(sessionId)) queues.set(sessionId, []);
|
|
869
|
+
queues.get(sessionId).push(prompt);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
// Topic lifecycle
|
|
874
|
+
// ---------------------------------------------------------------------------
|
|
875
|
+
// Claude Code's own session name (e.g. "documents-f7"), from ~/.claude/sessions/*.json, so a topic
|
|
876
|
+
// is named the same as the session you see in the editor / picker. Falls back to the first message.
|
|
877
|
+
function sessionNameById(sessionId) {
|
|
878
|
+
const dir = path.join(process.env.HOME, '.claude', 'sessions');
|
|
879
|
+
try {
|
|
880
|
+
for (const f of fs.readdirSync(dir)) {
|
|
881
|
+
if (!f.endsWith('.json')) continue;
|
|
882
|
+
try { const o = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); if (o.sessionId === sessionId && o.name) return o.name; } catch (e) { /* */ }
|
|
883
|
+
}
|
|
884
|
+
} catch (e) { /* */ }
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
function slugify(s) {
|
|
888
|
+
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 4).join('-').slice(0, 32);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Ask a small/fast model for a short kebab-case slug (VS Code-tab style) from the session's content.
|
|
892
|
+
// Runs in HOME (never a mapped repo) with a throwaway session id that we delete afterward, so title
|
|
893
|
+
// generation never leaves a stray session file that could itself spawn a topic.
|
|
894
|
+
function generateTitle(firstMsg, recentMsg) {
|
|
895
|
+
return new Promise((resolve) => {
|
|
896
|
+
if (!firstMsg && !recentMsg) return resolve(null);
|
|
897
|
+
const prompt = `Give a 1-3 word kebab-case slug titling this work session. Reply ONLY the slug, no quotes or extra text.\n` +
|
|
898
|
+
`First message: "${(firstMsg || '').slice(0, 300)}"` + (recentMsg ? `\nRecent: "${recentMsg.slice(0, 200)}"` : '');
|
|
899
|
+
const home = process.env.HOME;
|
|
900
|
+
const tmpId = crypto.randomUUID();
|
|
901
|
+
const cleanup = () => { try { const enc = '-' + home.replace(/^\//, '').replace(/[/.]/g, '-'); fs.unlinkSync(path.join(home, '.claude', 'projects', enc, tmpId + '.jsonl')); } catch (e) { /* */ } };
|
|
902
|
+
let out = '', done = false;
|
|
903
|
+
const finish = (v) => { if (done) return; done = true; cleanup(); resolve(v); };
|
|
904
|
+
let child; try { child = spawn(CLAUDE_BINARY, ['-p', '--session-id', tmpId, '--model', TITLE_MODEL, '--permission-mode', 'bypassPermissions'], { cwd: home, env: process.env }); }
|
|
905
|
+
catch (e) { return resolve(null); }
|
|
906
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch (e) { /* */ } finish(null); }, 20000);
|
|
907
|
+
child.stdout.on('data', (d) => (out += d));
|
|
908
|
+
child.on('error', () => { clearTimeout(timer); finish(null); });
|
|
909
|
+
child.on('close', () => { clearTimeout(timer); finish(slugify(out) || null); });
|
|
910
|
+
child.stdin.write(prompt); child.stdin.end();
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Sync fallback namer (also used in tests).
|
|
915
|
+
function topicName(info) {
|
|
916
|
+
const name = TITLE_MODE === 'first-message' ? slugify(info.label) : sessionNameById(info.id);
|
|
917
|
+
if (name) return `๐ค ${name}`;
|
|
918
|
+
return info.label ? `๐ค ${slugify(info.label)}` : `๐ค claude-${info.id.slice(0, 6)}`;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Async resolver used at topic creation โ generates a slug when TITLE_MODE=generated.
|
|
922
|
+
async function resolveTopicName(info) {
|
|
923
|
+
if (TITLE_MODE === 'generated') {
|
|
924
|
+
const { lastUser } = lastExchange(info.path);
|
|
925
|
+
const slug = await generateTitle(info.label, lastUser);
|
|
926
|
+
if (slug) return `๐ค ${slug}`;
|
|
927
|
+
}
|
|
928
|
+
return topicName(info);
|
|
929
|
+
}
|
|
930
|
+
function openerText(info) {
|
|
931
|
+
const name = sessionNameById(info.id);
|
|
932
|
+
return `๐ค Session ${name || info.id.slice(0, 8)}` +
|
|
933
|
+
(info.label ? `\nโ${info.label}โ` : '') +
|
|
934
|
+
`\nLast active ${relTime(info.mtime)}.\n\n` +
|
|
935
|
+
`This topic mirrors the desk session live. Reply here to steer it โ your message runs when the ` +
|
|
936
|
+
`desk session is idle. Back at the Mac, run \`cr\` (claude -c) to resume with your phone turns included.`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function ensureTopicForSession(info) {
|
|
940
|
+
if (linkBySession[info.id] || ignoredSessions.has(info.id)) return linkBySession[info.id] || null;
|
|
941
|
+
if (!shouldAutoCreate(info)) return null; // skip sub-agent/empty/command-only sessions
|
|
942
|
+
const chatId = repoToChat[info.cwd];
|
|
943
|
+
if (!chatId || !AUTO_CREATE_TOPICS) return null;
|
|
944
|
+
const threadId = await createForumTopic(chatId, await resolveTopicName(info));
|
|
945
|
+
if (!threadId) return null;
|
|
946
|
+
const tkey = `${chatId}_${threadId}`;
|
|
947
|
+
if (sessionByThread.has(tkey) && sessionByThread.get(tkey) !== info.id) { // never bind two sessions to one thread
|
|
948
|
+
console.warn(`[Topic] thread ${threadId} already bound to ${sessionByThread.get(tkey).slice(0, 8)}; skipping duplicate for ${info.id.slice(0, 8)}`);
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
linkBySession[info.id] = { chatId, threadId, label: info.label || '', offset: info.size || 0, closed: false };
|
|
952
|
+
sessionByThread.set(tkey, info.id);
|
|
953
|
+
persistLinks();
|
|
954
|
+
await sendPlain(chatId, threadId, openerText(info));
|
|
955
|
+
// Seed the topic with where the session left off (last prompt + last response).
|
|
956
|
+
const { lastText, lastUser } = lastExchange(info.path);
|
|
957
|
+
if (lastText) {
|
|
958
|
+
await sendPlain(chatId, threadId,
|
|
959
|
+
`โ where it left off โ${lastUser ? `\n๐ฅ๏ธ desk: ${lastUser.slice(0, 400)}` : ''}\n\n${lastText}`);
|
|
960
|
+
}
|
|
961
|
+
console.log(`[Topic] created for ${info.id.slice(0, 8)} โ chat ${chatId} thread ${threadId}`);
|
|
962
|
+
return linkBySession[info.id];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function pruneTopic(sessionId) {
|
|
966
|
+
const l = linkBySession[sessionId];
|
|
967
|
+
if (!l) return;
|
|
968
|
+
if (PRUNE_MODE === 'delete') {
|
|
969
|
+
await deleteForumTopic(l.chatId, l.threadId);
|
|
970
|
+
sessionByThread.delete(`${l.chatId}_${l.threadId}`);
|
|
971
|
+
delete linkBySession[sessionId];
|
|
972
|
+
} else {
|
|
973
|
+
await closeForumTopic(l.chatId, l.threadId);
|
|
974
|
+
l.closed = true;
|
|
975
|
+
}
|
|
976
|
+
delete pendingTools[sessionId];
|
|
977
|
+
persistLinks();
|
|
978
|
+
console.log(`[Topic] pruned (${PRUNE_MODE}) session ${sessionId.slice(0, 8)}`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function reviveTopic(sessionId) {
|
|
982
|
+
const l = linkBySession[sessionId];
|
|
983
|
+
if (!l || !l.closed) return;
|
|
984
|
+
await reopenForumTopic(l.chatId, l.threadId);
|
|
985
|
+
l.closed = false;
|
|
986
|
+
persistLinks();
|
|
987
|
+
console.log(`[Topic] reopened session ${sessionId.slice(0, 8)}`);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
// Poll loop: discover new sessions, mirror activity, prune, flush queues.
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// Graceful self-restart: `touch restart.flag` (from anywhere โ including a phone-driven turn) and
|
|
994
|
+
// the gateway exits once no injected turns are in flight; launchd relaunches it with fresh code.
|
|
995
|
+
// Never `launchctl kickstart -k` from inside a gateway-driven turn โ that kills the process group,
|
|
996
|
+
// including the turn that issued it, mid-command.
|
|
997
|
+
const RESTART_FLAG = path.join(__dirname, 'restart.flag');
|
|
998
|
+
|
|
999
|
+
let polling = false;
|
|
1000
|
+
async function pollTick() {
|
|
1001
|
+
if (polling) return;
|
|
1002
|
+
polling = true;
|
|
1003
|
+
try {
|
|
1004
|
+
if (fs.existsSync(RESTART_FLAG) && injecting.size === 0) {
|
|
1005
|
+
try { fs.unlinkSync(RESTART_FLAG); } catch (e) { /* */ }
|
|
1006
|
+
console.log('[Restart] restart.flag seen and no turns in flight โ exiting for launchd relaunch.');
|
|
1007
|
+
persistLinks();
|
|
1008
|
+
process.exit(1); // non-zero โ KeepAlive relaunches
|
|
1009
|
+
}
|
|
1010
|
+
const now = Date.now();
|
|
1011
|
+
let topicsThisTick = 0;
|
|
1012
|
+
const files = allSessionFiles();
|
|
1013
|
+
const existingIds = new Set(files.map((f) => path.basename(f, '.jsonl')));
|
|
1014
|
+
|
|
1015
|
+
for (const file of files) {
|
|
1016
|
+
const id = path.basename(file, '.jsonl');
|
|
1017
|
+
let st; try { st = fs.statSync(file); } catch (e) { continue; }
|
|
1018
|
+
const cwd = await getCwd(file);
|
|
1019
|
+
if (!cwd || !repoToChat[cwd]) continue;
|
|
1020
|
+
|
|
1021
|
+
if (id.startsWith('agent-')) continue; // sub-agent sessions never get a topic
|
|
1022
|
+
const link = linkBySession[id];
|
|
1023
|
+
if (!link) {
|
|
1024
|
+
if (ignoredSessions.has(id)) continue; // /new-detached โ permanent
|
|
1025
|
+
if (supersededAt[id] !== undefined) { // desk branch we forked away from
|
|
1026
|
+
if (st.size > supersededAt[id]) { delete supersededAt[id]; persistSuperseded(); } // desk kept working โ re-topic
|
|
1027
|
+
else continue; // still at the fork point โ stay hidden
|
|
1028
|
+
}
|
|
1029
|
+
const grew = st.size > (sessionBaseline[id] || 0); // grew past startup size (new files: baseline 0 โ eligible)
|
|
1030
|
+
// Discover: real, active, progressing sessions. At most ONE creation per tick so a burst of
|
|
1031
|
+
// active sessions can never hammer Telegram's rate limit again.
|
|
1032
|
+
if (MIRROR && AUTO_CREATE_TOPICS && !injecting.has(id) && grew && isActive(st.mtimeMs, now) && topicsThisTick < 1) {
|
|
1033
|
+
if (await ensureTopicForSession(await readSessionInfo(file))) topicsThisTick++;
|
|
1034
|
+
}
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (!link.closed && shouldPrune(st.mtimeMs, now)) { await pruneTopic(id); continue; }
|
|
1038
|
+
if (link.closed) { if (isActive(st.mtimeMs, now)) await reviveTopic(id); else continue; }
|
|
1039
|
+
|
|
1040
|
+
// Mirror new lines, throttled per topic so a busy desk session can't exceed Telegram limits.
|
|
1041
|
+
if (MIRROR && !injecting.has(id) && st.size > link.offset) {
|
|
1042
|
+
if (now - (lastMirrorAt.get(id) || 0) < MIRROR_FLUSH_MS) continue; // retry next poll; offset unchanged
|
|
1043
|
+
const { lines, newOffset } = readNewLines(file, link.offset);
|
|
1044
|
+
const posts = [];
|
|
1045
|
+
for (const o of lines) posts.push(...renderTranscriptLine(o, SHOW_TOOLS));
|
|
1046
|
+
// Track unresolved tool calls; announce completion of any we'd flagged as stalled.
|
|
1047
|
+
const pstate = (pendingTools[id] = pendingTools[id] || {});
|
|
1048
|
+
for (const r of updatePendingTools(pstate, lines, now)) {
|
|
1049
|
+
posts.push(`โถ๏ธ ${r.name} finished โ session continuing.`);
|
|
1050
|
+
}
|
|
1051
|
+
if (posts.length) {
|
|
1052
|
+
const sent = await sendPlain(link.chatId, link.threadId, posts.join('\n\n'));
|
|
1053
|
+
if (!sent) continue; // network hiccup: keep the offset so these lines retry next tick
|
|
1054
|
+
lastMirrorAt.set(id, now);
|
|
1055
|
+
}
|
|
1056
|
+
link.offset = newOffset;
|
|
1057
|
+
persistLinks();
|
|
1058
|
+
}
|
|
1059
|
+
// Stall notice: a tool call unresolved past the threshold โ slow tool, or a permission
|
|
1060
|
+
// prompt sitting unanswered at the desk (the transcript can't tell which; say both).
|
|
1061
|
+
for (const e of dueStallNotices(pendingTools[id], now, STALL_NOTICE_MS)) {
|
|
1062
|
+
await sendPlain(link.chatId, link.threadId,
|
|
1063
|
+
`โณ Desk session has been on this for ${Math.round((now - e.ts) / 1000)}s:\n๐ง ${e.name}${e.summary ? ': ' + e.summary : ''}\n` +
|
|
1064
|
+
`It may just be running long โ or waiting for tool approval at the desk (/desk opens it).`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Cleanup: drop links whose transcript file no longer exists (session deleted on disk).
|
|
1069
|
+
for (const id of Object.keys(linkBySession)) {
|
|
1070
|
+
if (existingIds.has(id)) continue;
|
|
1071
|
+
const l = linkBySession[id];
|
|
1072
|
+
if (l && !l.closed) await closeForumTopic(l.chatId, l.threadId);
|
|
1073
|
+
if (l) sessionByThread.delete(`${l.chatId}_${l.threadId}`);
|
|
1074
|
+
delete linkBySession[id];
|
|
1075
|
+
lastMirrorAt.delete(id);
|
|
1076
|
+
delete pendingTools[id];
|
|
1077
|
+
persistLinks();
|
|
1078
|
+
console.log(`[Cleanup] dropped link for missing session ${id.slice(0, 8)}`);
|
|
1079
|
+
}
|
|
1080
|
+
// Flush queued phone messages for now-idle sessions.
|
|
1081
|
+
for (const [sessionId, prompts] of queues) {
|
|
1082
|
+
if (!prompts.length) { queues.delete(sessionId); continue; }
|
|
1083
|
+
const l = linkBySession[sessionId];
|
|
1084
|
+
if (!l) { queues.delete(sessionId); continue; }
|
|
1085
|
+
if (injecting.has(sessionId)) continue;
|
|
1086
|
+
let mtime = 0; try { mtime = fs.statSync(sessionFileById(sessionId)).mtimeMs; } catch (e) { /* */ }
|
|
1087
|
+
if (isDeskBusy(mtime, now)) continue;
|
|
1088
|
+
scheduleDrive(l.chatId, l.threadId, prompts.shift(), sessionId);
|
|
1089
|
+
}
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
console.error('pollTick error:', err.message);
|
|
1092
|
+
} finally {
|
|
1093
|
+
polling = false;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ---------------------------------------------------------------------------
|
|
1098
|
+
// Telegram update polling (inbound messages)
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
1100
|
+
let lastUpdateId = 0;
|
|
1101
|
+
async function pollUpdates() {
|
|
1102
|
+
try {
|
|
1103
|
+
const res = await telegramRequest('getUpdates', { offset: lastUpdateId + 1, timeout: 30 });
|
|
1104
|
+
if (res.ok && res.result.length > 0) {
|
|
1105
|
+
for (const update of res.result) {
|
|
1106
|
+
lastUpdateId = update.update_id;
|
|
1107
|
+
|
|
1108
|
+
// Inline-button presses (phone approvals).
|
|
1109
|
+
const cb = update.callback_query;
|
|
1110
|
+
if (cb) {
|
|
1111
|
+
const fromId = cb.from ? String(cb.from.id) : '';
|
|
1112
|
+
if (!ALLOWED_USER_IDS.includes(fromId)) {
|
|
1113
|
+
telegramRequest('answerCallbackQuery', { callback_query_id: cb.id, text: 'Not authorized' }).catch(() => {});
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
const m = /^ap:(\d+):([01])$/.exec(cb.data || '');
|
|
1117
|
+
if (m) {
|
|
1118
|
+
const allowed = m[2] === '1';
|
|
1119
|
+
const meta = approvals.resolve(m[1], allowed, fromId);
|
|
1120
|
+
telegramRequest('answerCallbackQuery', { callback_query_id: cb.id, text: meta ? (allowed ? 'Allowed โ
' : 'Denied โ') : 'Already handled' }).catch(() => {});
|
|
1121
|
+
if (meta && cb.message) {
|
|
1122
|
+
telegramRequest('editMessageText', {
|
|
1123
|
+
chat_id: cb.message.chat.id, message_id: cb.message.message_id,
|
|
1124
|
+
text: `${cb.message.text}\n\n${allowed ? 'โ
Allowed' : 'โ Denied'}`,
|
|
1125
|
+
}).catch(() => {});
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const message = update.message;
|
|
1132
|
+
if (!message) continue;
|
|
1133
|
+
|
|
1134
|
+
const senderId = message.from ? message.from.id.toString() : null;
|
|
1135
|
+
const chatId = message.chat.id.toString();
|
|
1136
|
+
const threadId = message.message_thread_id;
|
|
1137
|
+
const text = message.text;
|
|
1138
|
+
|
|
1139
|
+
if (senderId === BOT_ID) continue; // ignore our own posts / forum service messages
|
|
1140
|
+
if (!ALLOWED_USER_IDS.includes(senderId)) { console.warn(`[Blocked] user ${senderId}`); continue; }
|
|
1141
|
+
if (!REPO_MAPPINGS[chatId]) { console.warn(`[Config] chat ${chatId} not mapped`); continue; }
|
|
1142
|
+
if (!threadId) {
|
|
1143
|
+
telegramRequest('sendMessage', { chat_id: chatId, text: "โ ๏ธ Please send commands inside a topic thread." }).catch(() => {});
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (!text) continue;
|
|
1147
|
+
|
|
1148
|
+
const key = `${chatId}_${threadId}`;
|
|
1149
|
+
|
|
1150
|
+
if (text === '/start') {
|
|
1151
|
+
sendPlain(chatId, threadId, "๐ Claude Code gateway ready. Active desk sessions auto-appear as topics and mirror live.\n\n" +
|
|
1152
|
+
"โข reply in a topic to steer that session\nโข /new <msg> โ fresh session in its own topic\nโข /desk โ open this session in the editor on your Mac\nโข /sessions, /resume <id|text>");
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// /desk (or /open) โ open this topic's session in the desktop editor on the Mac.
|
|
1157
|
+
if (text === '/desk' || text === '/open') {
|
|
1158
|
+
const sid = sessionByThread.get(key);
|
|
1159
|
+
if (!sid) { sendPlain(chatId, threadId, "No session is linked to this topic yet โ send a message first."); continue; }
|
|
1160
|
+
sendPlain(chatId, threadId, openOnDesk(sid)
|
|
1161
|
+
? "๐ฅ๏ธ Opening this session in the editor on your Mac."
|
|
1162
|
+
: "โ ๏ธ Couldn't open it on the Mac (is the editor installed and the URL scheme registered?).");
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// /new <message> โ brand-new session in its own new topic.
|
|
1167
|
+
if (text.startsWith('/new ')) {
|
|
1168
|
+
const firstMsg = text.substring(5).trim();
|
|
1169
|
+
const newThread = await createForumTopic(chatId, `๐ค ${firstMsg.slice(0, 50)}`);
|
|
1170
|
+
if (!newThread) { sendPlain(chatId, threadId, "โ ๏ธ Couldn't create a topic โ grant the bot admin + Manage Topics."); continue; }
|
|
1171
|
+
scheduleDrive(chatId, newThread, firstMsg, null);
|
|
1172
|
+
sendPlain(chatId, threadId, "๐ New session started in its own topic.");
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
// /new (bare) โ detach this topic so the next message starts a fresh session here.
|
|
1176
|
+
if (text === '/new' || text === '/reset') {
|
|
1177
|
+
const sid = sessionByThread.get(key);
|
|
1178
|
+
if (sid) { ignoredSessions.add(sid); persistIgnored(); sessionByThread.delete(key); delete linkBySession[sid]; persistLinks(); }
|
|
1179
|
+
sendPlain(chatId, threadId, "๐ This topic will start a fresh session on your next message.");
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
// /exit โ close this session's topic and stop mirroring it. The session file stays on disk
|
|
1183
|
+
// (resume any time via /sessions or `cr`); if the desk keeps working it, it re-topics itself.
|
|
1184
|
+
if (text === '/exit' || text === '/close') {
|
|
1185
|
+
const sid = sessionByThread.get(key);
|
|
1186
|
+
if (!sid) { sendPlain(chatId, threadId, "This topic isn't bound to a session โ nothing to close."); continue; }
|
|
1187
|
+
await sendPlain(chatId, threadId, `๐ Session ${sid.slice(0, 8)} closed. It stays resumable on disk ` +
|
|
1188
|
+
`(/sessions in another topic, or \`cr\` at the Mac); fresh desk activity will re-open a topic for it.`);
|
|
1189
|
+
sessionByThread.delete(key);
|
|
1190
|
+
delete linkBySession[sid];
|
|
1191
|
+
queues.delete(sid);
|
|
1192
|
+
delete pendingTools[sid];
|
|
1193
|
+
supersededAt[sid] = sizeCurrent(sid); persistSuperseded(); // hidden unless the desk grows it again
|
|
1194
|
+
persistLinks();
|
|
1195
|
+
if (PRUNE_MODE === 'delete') await deleteForumTopic(chatId, threadId);
|
|
1196
|
+
else await closeForumTopic(chatId, threadId);
|
|
1197
|
+
console.log(`[Exit] closed topic ${threadId} for session ${sid.slice(0, 8)}`);
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
// /sessions or bare /resume โ list recent sessions.
|
|
1201
|
+
if (text === '/sessions' || text === '/resume') {
|
|
1202
|
+
const sessions = await listSessions(REPO_MAPPINGS[chatId]);
|
|
1203
|
+
sendPlain(chatId, threadId, sessions.length
|
|
1204
|
+
? `๐ Recent sessions:\n\n${formatSessionList(sessions)}\n\nReply /resume <id> to link this topic to one.`
|
|
1205
|
+
: "No past Claude sessions found for this repo yet.");
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
// /resume <uuid | search text>
|
|
1209
|
+
if (text.startsWith('/resume ')) {
|
|
1210
|
+
const term = text.substring(8).trim();
|
|
1211
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(term);
|
|
1212
|
+
if (isUuid) {
|
|
1213
|
+
await upsertLink(term, chatId, threadId); persistLinks();
|
|
1214
|
+
sendPlain(chatId, threadId, `๐ Topic linked to session ${term}. Send a message to continue it.`);
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const matches = await matchSessions(REPO_MAPPINGS[chatId], term);
|
|
1218
|
+
if (matches.length === 0) sendPlain(chatId, threadId, `โ ๏ธ No session matching "${term}". Send /sessions to list recent ones.`);
|
|
1219
|
+
else if (matches.length === 1) {
|
|
1220
|
+
await upsertLink(matches[0].id, chatId, threadId, matches[0].label); persistLinks();
|
|
1221
|
+
sendPlain(chatId, threadId, `๐ Linked to "${matches[0].label || matches[0].id}"\n (${matches[0].id})\nSend a message to continue it.`);
|
|
1222
|
+
} else {
|
|
1223
|
+
sendPlain(chatId, threadId, `Multiple sessions match "${term}":\n\n${formatSessionList(matches)}\n\nReply /resume <id> to pick one.`);
|
|
1224
|
+
}
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Normal message: route to the session this topic is bound to. The session is re-resolved
|
|
1229
|
+
// when the turn RUNS (not now) so back-to-back replies follow a fork instead of re-forking.
|
|
1230
|
+
const sessionId = sessionByThread.get(key);
|
|
1231
|
+
if (sessionId) {
|
|
1232
|
+
let mtime = 0; try { mtime = fs.statSync(sessionFileById(sessionId)).mtimeMs; } catch (e) { /* */ }
|
|
1233
|
+
if (isDeskBusy(mtime)) {
|
|
1234
|
+
// Desk is actively writing this transcript right now โ a fork would branch mid-turn.
|
|
1235
|
+
queueForSession(sessionId, text);
|
|
1236
|
+
sendPlain(chatId, threadId, "โณ Desk session is mid-turn โ I'll send this when it settles.");
|
|
1237
|
+
} else {
|
|
1238
|
+
scheduleDrive(chatId, threadId, text, () => sessionByThread.get(key) || null);
|
|
1239
|
+
}
|
|
1240
|
+
} else {
|
|
1241
|
+
scheduleDrive(chatId, threadId, text, null); // fresh session bound to this topic
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
console.error('Error polling Telegram updates:', err.code || err.message || String(err));
|
|
1247
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
1248
|
+
}
|
|
1249
|
+
pollUpdates();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ---------------------------------------------------------------------------
|
|
1253
|
+
// Boot
|
|
1254
|
+
// ---------------------------------------------------------------------------
|
|
1255
|
+
const LOCK_FILE = path.join(__dirname, '.gateway.lock');
|
|
1256
|
+
function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
|
|
1257
|
+
function acquireLock() {
|
|
1258
|
+
try {
|
|
1259
|
+
if (fs.existsSync(LOCK_FILE)) {
|
|
1260
|
+
const pid = parseInt(fs.readFileSync(LOCK_FILE, 'utf8'), 10);
|
|
1261
|
+
if (pid && pid !== process.pid && pidAlive(pid)) {
|
|
1262
|
+
console.error(`Another gateway is already running (pid ${pid}). Exiting to avoid a getUpdates conflict.`);
|
|
1263
|
+
process.exit(1);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid));
|
|
1267
|
+
} catch (e) { /* */ }
|
|
1268
|
+
}
|
|
1269
|
+
function releaseLock() { try { if (fs.existsSync(LOCK_FILE) && parseInt(fs.readFileSync(LOCK_FILE, 'utf8'), 10) === process.pid) fs.unlinkSync(LOCK_FILE); } catch (e) { /* */ } }
|
|
1270
|
+
|
|
1271
|
+
function shutdown() {
|
|
1272
|
+
console.log("\n๐ [Gateway Shutdown] Exiting. Headless turns are short-lived; no orphaned sessions to kill.");
|
|
1273
|
+
persistLinks();
|
|
1274
|
+
releaseLock();
|
|
1275
|
+
process.exit(0);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (require.main === module) {
|
|
1279
|
+
// Timestamp every log line (launchd's log file has no timestamps of its own).
|
|
1280
|
+
for (const m of ['log', 'warn', 'error']) {
|
|
1281
|
+
const orig = console[m].bind(console);
|
|
1282
|
+
console[m] = (...a) => orig(new Date().toISOString(), ...a);
|
|
1283
|
+
}
|
|
1284
|
+
process.on('SIGINT', shutdown);
|
|
1285
|
+
process.on('SIGTERM', shutdown);
|
|
1286
|
+
|
|
1287
|
+
acquireLock();
|
|
1288
|
+
loadLinks();
|
|
1289
|
+
loadIgnored();
|
|
1290
|
+
loadSuperseded();
|
|
1291
|
+
snapshotBaseline(); // record current sizes so a restart doesn't mass-create topics
|
|
1292
|
+
console.log("=============================================");
|
|
1293
|
+
console.log("๐ CLAUDE CODE MULTI-SESSION TELEGRAM GATEWAY");
|
|
1294
|
+
console.log("=============================================");
|
|
1295
|
+
console.log(`Allowed admins: ${ALLOWED_USER_IDS.length} ยท repos: ${Object.keys(REPO_MAPPINGS).length}`);
|
|
1296
|
+
console.log(`Permission mode: ${PERM_MODE}${MODEL ? ` ยท model: ${MODEL}` : ''} ยท tools: ${SHOW_TOOLS ? 'on' : 'off'}`);
|
|
1297
|
+
console.log(`Mirror: ${MIRROR ? 'on' : 'off'} ยท auto-topics: ${AUTO_CREATE_TOPICS ? 'on' : 'off'} ยท prune: ${PRUNE_MODE} after ${PRUNE_AFTER_MS / 86400000}d`);
|
|
1298
|
+
console.log(`Restored ${Object.keys(linkBySession).length} linked session(s). Poll ${POLL_MS}ms.`);
|
|
1299
|
+
console.log("Listening for Topic messages + mirroring desk sessions...");
|
|
1300
|
+
pollUpdates();
|
|
1301
|
+
setInterval(pollTick, POLL_MS);
|
|
1302
|
+
pollTick();
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
module.exports = {
|
|
1306
|
+
LiveMessage, summarizeToolInput, createFeed, renderTranscriptLine, readNewLines,
|
|
1307
|
+
listSessions, matchSessions, readSessionInfo, relTime, formatSessionList,
|
|
1308
|
+
isActive, shouldPrune, isDeskBusy, invertRepoMappings, splitThreadKey, buildThreadIndex,
|
|
1309
|
+
migrateLegacy, topicName, openerText, shouldAutoCreate, loadIgnored, persistIgnored, persisted, deskUrl,
|
|
1310
|
+
lastExchange, sessionNameById, heldByOtherPids, updatePendingTools, dueStallNotices, createApprovalRegistry,
|
|
1311
|
+
};
|